From 95f989639d7afa2ee99072beec6791f25ec6cad3 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 20:47:33 +0200 Subject: [PATCH] feat(invites): drop literal token after claim; cleaner UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once an invite is claimed, the token has no functional role — claims are one-way and the link is dead. Stop returning the literal token in the GET /api/invites response for claimed entries (server/invites.ts toEntry). The audit trail — claimed_at, claimed_by_display — stays. Helps a little with data minimization: a compromised inviter account can no longer see used-up invitation URLs. Type: InviteEntry.token is now string | null. Callers that still need to use the token (signup-via-invite tests, the cancel button, the copy button) are guarded so they only run on entries where the token is present (i.e. unclaimed). The each-key falls back to a synthetic composite when token is null so Svelte's keyed-each stays stable. UI: claimed entries collapse to a single muted line, no card frame, no URL placeholder: ✓ Laget DD.MM.YYYY · godtatt av DD.MM.YYYY Unclaimed entries keep the existing card with copy / cancel buttons. Heading on the invite section also renamed from "Invitasjonslenker" to "Invitasjoner" — claimed entries don't have a link anymore so the older label was misleading. Tests updated to match by created_at instead of token for the claimed-invite lookup, and to assert that token is null post-claim. --- frontend/src/components/Profile.svelte | 48 +++++++++++++++----------- server/invites.ts | 7 +++- shared/types.ts | 5 +-- tests/social.test.ts | 17 +++++---- 4 files changed, 46 insertions(+), 31 deletions(-) diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 28fee8d..447a2a3 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -170,8 +170,11 @@ /** Build the shareable invite URL using the SPA's own origin. The server * used to compute this but in dev that yielded the API host (port 3000), * not the SPA host (port 5173). The token is the canonical artefact; - * the URL is just a presentation concern the SPA owns. */ - function inviteUrl(inv: InviteEntry): string { + * the URL is just a presentation concern the SPA owns. Returns null + * when the invite has been claimed — the server stops returning the + * token at that point and there's nothing left to share. */ + function inviteUrl(inv: InviteEntry): string | null { + if (!inv.token) return null; return `${window.location.origin}/invitasjon/${inv.token}`; } @@ -361,7 +364,7 @@
-

Invitasjonslenker

+

Invitasjoner

Generer en lenke du kan dele. Den som registrerer seg via lenken blir knyttet til deg som invitør. Hver lenke kan bare brukes én gang. @@ -378,31 +381,34 @@ {#if invites.length === 0}

Ingen invitasjoner ennå.

{/if} - {#each invites as inv (inv.token)} -
-
- {inviteUrl(inv)} - {formatDate(inv.created_at)} -
-
- {#if inv.claimed_at} - - {#if inv.claimed_by_display} - ✓ Mottatt og tatt i bruk av {inv.claimed_by_display} · {formatDate(inv.claimed_at)} - {:else} - ✓ Mottatt og tatt i bruk · {formatDate(inv.claimed_at)} - {/if} - + {#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)} + {#if inv.claimed_at} + +

+ ✓ Laget {formatDate(inv.created_at)} · + {#if inv.claimed_by_display} + godtatt av {inv.claimed_by_display} {formatDate(inv.claimed_at)} {:else} + godtatt {formatDate(inv.claimed_at)} + {/if} +

+ {:else} +
+
+ {inviteUrl(inv)} + {formatDate(inv.created_at)} +
+
- {/if} -
-
+
+
+ {/if} {/each}
diff --git a/server/invites.ts b/server/invites.ts index c3b1e32..939cf68 100644 --- a/server/invites.ts +++ b/server/invites.ts @@ -50,7 +50,12 @@ function toEntry(row: InviteRow): InviteEntry { } } return { - token: row.token, + // Once an invite is claimed the token has no functional role — claim + // is one-way, you can't re-claim — and we don't need the inviter to + // be able to re-share a now-dead link. Stripping it from the response + // also means a compromised inviter account doesn't leak used-up + // links. The audit trail (claimed_at, claimed_by_display) stays. + token: row.claimed_at ? null : row.token, created_at: row.created_at, claimed_at: row.claimed_at, claimed_by_display: claimedByDisplay, diff --git a/shared/types.ts b/shared/types.ts index e83fd61..280f98b 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -45,8 +45,9 @@ export interface InviteEntry { /** The token itself. The shareable URL is built client-side as * `${window.location.origin}/invitasjon/${token}` — server-side URL * construction would point at the API host in split-process dev - * environments. */ - token: string; + * environments. NULL when the invite has been claimed; the token has + * no functional role after that and we don't keep it in the response. */ + token: string | null; created_at: number; claimed_at: number | null; /** Display name (or username) of the user who claimed it, if any. */ diff --git a/tests/social.test.ts b/tests/social.test.ts index 991f157..54ea8b8 100644 --- a/tests/social.test.ts +++ b/tests/social.test.ts @@ -125,7 +125,7 @@ describe('invites', () => { expect(inv.claimed_at).toBeNull(); // A new user signs up with that invite token. - const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token); + const claimer = await signupAndGetCookie(ctx, 'inv-claimer@test.invalid', undefined, inv.token!); expect(claimer.me.id).toBeTruthy(); // The invited_by column on the new user row points at the inviter. @@ -133,13 +133,16 @@ describe('invites', () => { .get(claimer.me.id) as { invited_by: string | null }; expect(row.invited_by).toBe(inviter.me.id); - // The invite is now claimed in the inviter's list. + // The invite is now claimed in the inviter's list. The server stops + // returning the literal token once the invite is claimed, so match by + // created_at instead. const myInvites = await reqJson(ctx, 'GET', '/api/invites', { cookie: inviter.cookie, }); - const claimed = myInvites.find((i) => i.token === inv.token)!; + const claimed = myInvites.find((i) => i.created_at === inv.created_at)!; expect(claimed.claimed_at).not.toBeNull(); expect(claimed.claimed_by_display).toBe('inv-claimer'); + expect(claimed.token).toBeNull(); }); ttest('invite is single-use: re-claim is rejected', async () => { @@ -149,12 +152,12 @@ describe('invites', () => { }); // First claim succeeds. - await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token); + await signupAndGetCookie(ctx, 'inv-single-1@test.invalid', undefined, inv.token!); // Second signup with the same token: with self-registry OPEN (the // default), the bad token is silently dropped and signup proceeds // WITHOUT attribution. Confirm: signup ok, invited_by is null. - const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token); + const second = await signupAndGetCookie(ctx, 'inv-single-2@test.invalid', undefined, inv.token!); const row = getDb().prepare('SELECT invited_by FROM users WHERE id = ?') .get(second.me.id) as { invited_by: string | null }; expect(row.invited_by).toBeNull(); @@ -176,7 +179,7 @@ describe('invites', () => { const inv = await reqJson(ctx, 'POST', '/api/invites', { cookie: inviter.cookie, expect: 201, }); - await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token); + await signupAndGetCookie(ctx, 'inv-already-c@test.invalid', undefined, inv.token!); const cancelRes = await req(ctx, 'DELETE', `/api/invites/${inv.token}`, { cookie: inviter.cookie, }); @@ -232,7 +235,7 @@ describe('settings + signup gating', () => { const inv = await reqJson(ctx, 'POST', '/api/invites', { cookie: admin.cookie, expect: 201, }); - const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token); + const claimer = await signupAndGetCookie(ctx, 'gate-claimer@test.invalid', undefined, inv.token!); expect(claimer.me.id).toBeTruthy(); } finally { // Re-open self-registry so subsequent tests can still sign up.