From 28fa8badcbb838490c545ac2eb421ba131755e2d Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 13:13:03 -0400 Subject: [PATCH] invites: an admin lets one person register while registration stays closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now adding a second person meant re-opening registration to the whole internet while they signed up (#2939 §1). An admin now makes an invite in Settings: a link that works once, expires (7 days by default, 1 to 30), and can be pinned to one email address. Only the token's hash is stored, so the link is shown once. POST /api/auth/register takes `invite`. Redemption is one conditional UPDATE inside the transaction that creates the account, so two people racing one link can't both get in, and a taken email leaves the invite unused. Every refusal says "invalid or expired invite". The register page reads ?invite= and opens even while registration is closed. Refs #5172 Co-Authored-By: Claude Opus 5.5 --- alembic/versions/0032_invites.py | 42 +++++ docs/public-hosting.md | 8 +- frontend/src/adapters/repo.ts | 4 +- frontend/src/adapters/rest.ts | 4 +- frontend/src/components/InviteList.vue | 205 +++++++++++++++++++++++++ frontend/src/router/index.ts | 4 +- frontend/src/stores/session.ts | 4 +- frontend/src/views/RegisterView.vue | 17 +- frontend/src/views/SettingsView.vue | 5 + src/inkwell/app.py | 2 + src/inkwell/auth.py | 28 +++- src/inkwell/invites.py | 103 +++++++++++++ src/inkwell/invites_api.py | 95 ++++++++++++ src/inkwell/models/all.py | 1 + src/inkwell/models/invite.py | 40 +++++ src/inkwell/settings.py | 4 +- tests/test_integration.py | 140 ++++++++++++++++- tests/test_invites.py | 37 +++++ 18 files changed, 715 insertions(+), 28 deletions(-) create mode 100644 alembic/versions/0032_invites.py create mode 100644 frontend/src/components/InviteList.vue create mode 100644 src/inkwell/invites.py create mode 100644 src/inkwell/invites_api.py create mode 100644 src/inkwell/models/invite.py create mode 100644 tests/test_invites.py 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 @@