feat(invites): drop literal token after claim; cleaner UI
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 <bruker> 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.
This commit is contained in:
parent
2ac73c3515
commit
95f989639d
4 changed files with 46 additions and 31 deletions
|
|
@ -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 @@
|
|||
</section>
|
||||
|
||||
<section class="card" aria-labelledby="inv-h">
|
||||
<h3 id="inv-h">Invitasjonslenker</h3>
|
||||
<h3 id="inv-h">Invitasjoner</h3>
|
||||
<p class="muted">
|
||||
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}
|
||||
<p class="muted">Ingen invitasjoner ennå.</p>
|
||||
{/if}
|
||||
{#each invites as inv (inv.token)}
|
||||
<article class="card" style="margin-top: 0.5rem; {inv.claimed_at ? 'opacity: 0.7;' : ''}">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
|
||||
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
{#if inv.claimed_at}
|
||||
<span class="muted">
|
||||
{#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}
|
||||
</span>
|
||||
{#each invites as inv (inv.token ?? `c-${inv.created_at}-${inv.claimed_at}`)}
|
||||
{#if inv.claimed_at}
|
||||
<!-- Claimed: one-line summary. The token isn't returned anymore,
|
||||
so there's nothing to copy or cancel — just the audit trail. -->
|
||||
<p class="muted" style="margin: 0.4rem 0; font-size: 0.9rem;">
|
||||
✓ 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}
|
||||
</p>
|
||||
{:else}
|
||||
<article class="card" style="margin-top: 0.5rem;">
|
||||
<div class="row" style="justify-content: space-between;">
|
||||
<code style="font-size: 0.8rem; word-break: break-all;">{inviteUrl(inv)}</code>
|
||||
<span class="muted" style="white-space: nowrap;">{formatDate(inv.created_at)}</span>
|
||||
</div>
|
||||
<div class="row" style="margin-top: 0.5rem;">
|
||||
<button type="button" onclick={() => copyInviteUrl(inv)}>
|
||||
{copiedToken === inv.token ? 'Kopiert!' : 'Kopier lenke'}
|
||||
</button>
|
||||
<button class="danger" type="button" onclick={() => cancelInvite(inv)}>
|
||||
Avbryt
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue