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}
- {#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}
+
+
+ {/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.