feat(auth): admin invite links
Lint & Test / lint-and-check (push) Failing after 5m4s
Lint & Test / test (push) Has been skipped

Replaces the blunt registrationEnabled toggle with per-invite access.
Invites are tokenized, single-use, optionally locked to an email, can
grant user or admin role, and expire (default 7d, max 90d).

- Invite model with tokenHash (bcrypt), email, role, expiresAt,
  usedAt/usedByUserId.
- inviteService: create, list, revoke, findInviteByToken, consumeInvite.
  Token is shown exactly once at creation.
- /admin/invites page: list with status (Active/Used/Expired), generate
  with email lock + role + custom expiry, copy one-shot URL, revoke.
- /register?invite=TOKEN: accepts invite even when registrationEnabled
  is false; shows a banner; enforces email lock; applies the invite's
  role on creation; consumes the invite on success.
- Linked from the admin navbar.
This commit is contained in:
2026-04-16 04:00:18 +03:00
parent 9cab7262e6
commit 38335e925b
10 changed files with 530 additions and 11 deletions
+9
View File
@@ -0,0 +1,9 @@
import type { PageServerLoad } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import * as inviteService from '$lib/server/services/inviteService.js';
export const load: PageServerLoad = async (event) => {
requireAdmin(event);
const invites = await inviteService.listInvites();
return { invites };
};
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
let showForm = $state(false);
let creating = $state(false);
let err = $state<string | null>(null);
let email = $state('');
let role = $state<'user' | 'admin'>('user');
let expiresInDays = $state(7);
let createdUrl = $state<string | null>(null);
async function submit(evt: SubmitEvent) {
evt.preventDefault();
err = null;
creating = true;
try {
const res = await fetch('/api/admin/invites', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email || undefined,
role,
expiresInDays
})
});
const json = await res.json();
if (!json.success) {
err = json.error ?? 'Failed to create invite';
return;
}
createdUrl = json.data.url;
email = '';
role = 'user';
expiresInDays = 7;
showForm = false;
await invalidateAll();
} catch {
err = 'Failed to create invite';
} finally {
creating = false;
}
}
async function revoke(id: string) {
err = null;
try {
const res = await fetch(`/api/admin/invites/${id}`, { method: 'DELETE' });
const json = await res.json();
if (!json.success) {
err = json.error ?? 'Failed to revoke invite';
return;
}
await invalidateAll();
} catch {
err = 'Failed to revoke invite';
}
}
async function copyUrl(url: string) {
try {
await navigator.clipboard.writeText(url);
} catch {
/* ignore */
}
}
function fmt(d: string | Date | null): string {
if (!d) return '—';
return new Date(d).toLocaleString();
}
function inviteStatus(inv: { usedAt: Date | string | null; expiresAt: Date | string }): string {
if (inv.usedAt) return 'Used';
if (new Date(inv.expiresAt) < new Date()) return 'Expired';
return 'Active';
}
</script>
<svelte:head>
<title>Invites</title>
</svelte:head>
<div class="mx-auto max-w-4xl space-y-6 px-4 py-8">
<div class="flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-foreground">Invites</h1>
<p class="mt-1 text-sm text-muted-foreground">
Generate invite links so people can register even when public registration is disabled.
</p>
</div>
{#if !showForm}
<button
type="button"
onclick={() => (showForm = true)}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Generate invite
</button>
{/if}
</div>
{#if err}
<div class="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{err}
</div>
{/if}
{#if createdUrl}
<div class="rounded-lg border border-yellow-500/50 bg-yellow-500/10 p-4">
<h3 class="mb-1 text-sm font-semibold text-foreground">Invite created</h3>
<p class="mb-3 text-xs text-muted-foreground">
Share this link with the recipient. It will only be shown once.
</p>
<div class="flex items-center gap-2">
<code
class="flex-1 truncate rounded-md border border-input bg-background px-3 py-2 text-xs font-mono text-foreground"
>
{createdUrl}
</code>
<button
type="button"
onclick={() => createdUrl && copyUrl(createdUrl)}
class="rounded-md bg-primary px-3 py-2 text-xs font-medium text-primary-foreground hover:bg-primary/90"
>
Copy
</button>
</div>
<button
type="button"
onclick={() => (createdUrl = null)}
class="mt-3 text-xs text-muted-foreground hover:text-foreground"
>
I have copied the link
</button>
</div>
{/if}
{#if showForm}
<form
onsubmit={submit}
class="space-y-4 rounded-xl border border-border bg-card p-5"
>
<div>
<label for="inv-email" class="mb-1 block text-sm font-medium text-card-foreground">
Email <span class="text-xs font-normal text-muted-foreground">(optional — locks invite to one email)</span>
</label>
<input
id="inv-email"
type="email"
bind:value={email}
placeholder="jane@example.com"
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label for="inv-role" class="mb-1 block text-sm font-medium text-card-foreground">Role</label>
<select
id="inv-role"
bind:value={role}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
</div>
<div>
<label for="inv-expiry" class="mb-1 block text-sm font-medium text-card-foreground">
Expires in (days)
</label>
<input
id="inv-expiry"
type="number"
min="1"
max="90"
bind:value={expiresInDays}
class="w-full rounded-lg border border-input bg-background px-3 py-2 text-sm text-foreground"
/>
</div>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showForm = false)}
class="rounded-md border border-border px-3 py-2 text-sm text-foreground hover:bg-muted"
>
Cancel
</button>
<button
type="submit"
disabled={creating}
class="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
>
{creating ? 'Creating…' : 'Create invite'}
</button>
</div>
</form>
{/if}
<div class="overflow-hidden rounded-xl border border-border bg-card">
<table class="w-full text-sm">
<thead class="bg-muted/40 text-xs uppercase tracking-wide text-muted-foreground">
<tr>
<th class="px-4 py-3 text-left font-medium">Email</th>
<th class="px-4 py-3 text-left font-medium">Role</th>
<th class="px-4 py-3 text-left font-medium">Status</th>
<th class="px-4 py-3 text-left font-medium">Expires</th>
<th class="px-4 py-3 text-left font-medium">Created</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{#each data.invites as inv (inv.id)}
{@const status = inviteStatus(inv)}
<tr class="border-t border-border">
<td class="px-4 py-3 text-card-foreground">{inv.email ?? '—'}</td>
<td class="px-4 py-3 text-card-foreground">{inv.role}</td>
<td class="px-4 py-3">
<span
class="rounded-full px-2 py-0.5 text-xs {status === 'Active'
? 'bg-emerald-500/15 text-emerald-500'
: status === 'Used'
? 'bg-muted text-muted-foreground'
: 'bg-destructive/15 text-destructive'}"
>
{status}
</span>
</td>
<td class="px-4 py-3 text-muted-foreground">{fmt(inv.expiresAt)}</td>
<td class="px-4 py-3 text-muted-foreground">{fmt(inv.createdAt)}</td>
<td class="px-4 py-3 text-right">
<button
type="button"
onclick={() => revoke(inv.id)}
class="text-xs text-destructive hover:underline"
>
Revoke
</button>
</td>
</tr>
{/each}
{#if data.invites.length === 0}
<tr>
<td colspan="6" class="px-4 py-6 text-center text-sm text-muted-foreground">
No invites yet.
</td>
</tr>
{/if}
</tbody>
</table>
</div>
</div>