diff --git a/alembic/versions/0032_invites.py b/alembic/versions/0032_invites.py new file mode 100644 index 0000000..3490ec2 --- /dev/null +++ b/alembic/versions/0032_invites.py @@ -0,0 +1,42 @@ +"""invites: single-use, expiring registration links an admin issues + +Revision ID: 0032 +Revises: 0031 +Create Date: 2026-10-07 + +Until now the only way to add a second person was to re-open registration to the +whole internet while they signed up (#2939 §1). An invite lets one person register +while registration stays closed. Only the token's SHA-256 hash is stored. + +## Downgrade + +Drops the table. Accounts created through invites are untouched; the record of who +invited them goes with it. +""" +import sqlalchemy as sa +from alembic import op +from sqlalchemy.dialects.postgresql import CITEXT, UUID + +revision = "0032" +down_revision = "0031" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "invites", + sa.Column("id", UUID(as_uuid=True), primary_key=True), + sa.Column("token_hash", sa.Text(), nullable=False, unique=True), + sa.Column("created_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("email", CITEXT(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("redeemed_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("redeemed_by", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("invites") diff --git a/docs/public-hosting.md b/docs/public-hosting.md index ee68abe..b4607cc 100644 --- a/docs/public-hosting.md +++ b/docs/public-hosting.md @@ -20,8 +20,9 @@ first account is created, so a server whose admin already existed keeps whatever Access → Allow new registrations** before exposing an instance you have been running on a LAN. -To let someone else in, turn it back on, have them register, turn it off. There is no -invite system yet, so that is the mechanism. +To let someone else in, send them an invite: **Settings → Invites** makes a link that +works once, expires (a week by default), and can be pinned to one email address. It +lets that one person register while registration stays closed. **2. Terminate TLS in front of it, and forward the scheme.** The app marks the session cookie `Secure` and sends HSTS only when it can tell the request arrived over @@ -121,9 +122,6 @@ Know these before you decide who gets an account. with `docker compose logs app`, which is enough to see whether anyone is knocking. They are not queryable, not retained beyond the container's log rotation, and not attributable after the fact. -- **No invites.** Adding a second person means re-opening registration while they - sign up, then closing it again. There is no per-person token, no expiry, and no - record of who invited whom. None of these are hard blockers for an instance whose accounts are you and people you know. They are the reason not to hand out open registration to strangers. diff --git a/frontend/src/adapters/repo.ts b/frontend/src/adapters/repo.ts index b832d52..367ad1e 100644 --- a/frontend/src/adapters/repo.ts +++ b/frontend/src/adapters/repo.ts @@ -66,7 +66,9 @@ export interface ConfigRepo { export interface AuthRepo { me(): Promise; login(email: string, password: string): Promise; - register(email: string, password: string, displayName: string): Promise; + /** `invite` is the token from an invite link, which lets this one account in + * while registration is closed. */ + register(email: string, password: string, displayName: string, invite?: string): Promise; logout(): Promise; } diff --git a/frontend/src/adapters/rest.ts b/frontend/src/adapters/rest.ts index 916820e..015b91b 100644 --- a/frontend/src/adapters/rest.ts +++ b/frontend/src/adapters/rest.ts @@ -53,8 +53,8 @@ export const rest: Repo = { auth: { me: () => api.get("/api/auth/me"), login: (email, password) => api.post("/api/auth/login", { email, password }), - register: (email, password, displayName) => - api.post("/api/auth/register", { email, password, display_name: displayName }), + register: (email, password, displayName, invite) => + api.post("/api/auth/register", { email, password, display_name: displayName, invite }), logout: () => api.post("/api/auth/logout"), }, diff --git a/frontend/src/components/InviteList.vue b/frontend/src/components/InviteList.vue new file mode 100644 index 0000000..fb06879 --- /dev/null +++ b/frontend/src/components/InviteList.vue @@ -0,0 +1,205 @@ + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 5cc1fca..9b5ce82 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -112,7 +112,9 @@ router.beforeEach(async (to) => { if (to.meta.requiresServer && isDesktop()) { return { name: "board" }; } - if (to.name === "register" && !config.allowRegistration) { + // An invite link opens the form while registration is closed; the server decides + // whether the invite holds. + if (to.name === "register" && !config.allowRegistration && !to.query.invite) { return { name: "login" }; } if (to.meta.guestOnly && session.user) { diff --git a/frontend/src/stores/session.ts b/frontend/src/stores/session.ts index 79c227f..acd8a7c 100644 --- a/frontend/src/stores/session.ts +++ b/frontend/src/stores/session.ts @@ -29,8 +29,8 @@ export const useSessionStore = defineStore("session", () => { user.value = await repo.auth.login(email, password); } - async function register(email: string, password: string, displayName: string): Promise { - user.value = await repo.auth.register(email, password, displayName); + async function register(email: string, password: string, displayName: string, invite?: string): Promise { + user.value = await repo.auth.register(email, password, displayName, invite); } async function logout(): Promise { diff --git a/frontend/src/views/RegisterView.vue b/frontend/src/views/RegisterView.vue index 967c380..d583624 100644 --- a/frontend/src/views/RegisterView.vue +++ b/frontend/src/views/RegisterView.vue @@ -1,6 +1,6 @@