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
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "Invite" (
"id" TEXT NOT NULL PRIMARY KEY,
"tokenHash" TEXT NOT NULL,
"email" TEXT,
"role" TEXT NOT NULL DEFAULT 'user',
"expiresAt" DATETIME NOT NULL,
"usedAt" DATETIME,
"usedByUserId" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- CreateIndex
CREATE UNIQUE INDEX "Invite_tokenHash_key" ON "Invite"("tokenHash");
-- CreateIndex
CREATE INDEX "Invite_tokenHash_idx" ON "Invite"("tokenHash");
-- CreateIndex
CREATE INDEX "Invite_createdById_idx" ON "Invite"("createdById");
+15
View File
@@ -41,6 +41,21 @@ model User {
@@index([email])
}
model Invite {
id String @id @default(cuid())
tokenHash String @unique
email String? // optional — lock the invite to a specific email
role String @default("user") // user | admin
expiresAt DateTime
usedAt DateTime?
usedByUserId String?
createdById String?
createdAt DateTime @default(now())
@@index([tokenHash])
@@index([createdById])
}
model Session {
id String @id @default(cuid())
userId String
+100
View File
@@ -0,0 +1,100 @@
import bcrypt from 'bcryptjs';
import { prisma } from '../prisma.js';
const SALT_ROUNDS = 12;
const DEFAULT_EXPIRY_DAYS = 7;
export interface CreateInviteInput {
readonly email?: string;
readonly role?: 'user' | 'admin';
readonly expiresInDays?: number;
readonly createdById?: string;
}
export interface IssuedInvite {
readonly id: string;
readonly token: string; // raw token — shown to admin exactly once
readonly email: string | null;
readonly role: string;
readonly expiresAt: Date;
}
function generateToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export async function createInvite(input: CreateInviteInput): Promise<IssuedInvite> {
const token = generateToken();
const tokenHash = await bcrypt.hash(token, SALT_ROUNDS);
const days = input.expiresInDays ?? DEFAULT_EXPIRY_DAYS;
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + days);
const invite = await prisma.invite.create({
data: {
tokenHash,
email: input.email ?? null,
role: input.role ?? 'user',
expiresAt,
createdById: input.createdById ?? null
}
});
return {
id: invite.id,
token,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt
};
}
export async function listInvites() {
return prisma.invite.findMany({
orderBy: { createdAt: 'desc' },
select: {
id: true,
email: true,
role: true,
expiresAt: true,
usedAt: true,
usedByUserId: true,
createdById: true,
createdAt: true
}
});
}
export async function revokeInvite(id: string): Promise<void> {
await prisma.invite.deleteMany({ where: { id } });
}
/**
* Look up an invite by raw token. Performs a full table scan of non-expired,
* non-used invites and bcrypt-compares each. Invite volume is tiny in practice,
* so the scan cost is negligible.
*/
export async function findInviteByToken(token: string) {
const candidates = await prisma.invite.findMany({
where: {
usedAt: null,
expiresAt: { gt: new Date() }
}
});
for (const invite of candidates) {
if (await bcrypt.compare(token, invite.tokenHash)) {
return invite;
}
}
return null;
}
export async function consumeInvite(inviteId: string, userId: string): Promise<void> {
await prisma.invite.update({
where: { id: inviteId },
data: { usedAt: new Date(), usedByUserId: userId }
});
}
+1
View File
@@ -8,6 +8,7 @@
const navItems = $derived([
{ href: '/admin/users', labelKey: 'admin.users' },
{ href: '/admin/invites', label: 'Invites' },
{ href: '/admin/groups', labelKey: 'admin.groups' },
{ href: '/admin/tags', label: 'Tags' },
{ href: '/admin/audit-log', label: 'Audit Log' },
+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>
+60
View File
@@ -0,0 +1,60 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { z } from 'zod';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { success, error } from '$lib/server/utils/response.js';
import * as inviteService from '$lib/server/services/inviteService.js';
const createInviteSchema = z.object({
email: z.string().email().optional(),
role: z.enum(['user', 'admin']).optional(),
expiresInDays: z.number().int().min(1).max(90).optional()
});
export const GET: RequestHandler = async (event) => {
requireAdmin(event);
const invites = await inviteService.listInvites();
return json(success(invites));
};
export const POST: RequestHandler = async (event) => {
const admin = requireAdmin(event);
let body: unknown;
try {
body = await event.request.json();
} catch {
body = {};
}
const parsed = createInviteSchema.safeParse(body);
if (!parsed.success) {
const msg = parsed.error.errors.map((e) => e.message).join(', ');
return json(error(msg), { status: 400 });
}
try {
const invite = await inviteService.createInvite({
...parsed.data,
createdById: admin.id
});
const origin = event.request.headers.get('origin') ?? event.url.origin;
const inviteUrl = `${origin}/register?invite=${invite.token}`;
return json(
success({
id: invite.id,
email: invite.email,
role: invite.role,
expiresAt: invite.expiresAt,
token: invite.token,
url: inviteUrl
}),
{ status: 201 }
);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create invite';
return json(error(message), { status: 500 });
}
};
@@ -0,0 +1,19 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { requireAdmin } from '$lib/server/middleware/authorize.js';
import { success, error } from '$lib/server/utils/response.js';
import * as inviteService from '$lib/server/services/inviteService.js';
export const DELETE: RequestHandler = async (event) => {
requireAdmin(event);
const id = event.params.id;
if (!id) return json(error('Invite id required'), { status: 400 });
try {
await inviteService.revokeInvite(id);
return json(success({ revoked: true }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke invite';
return json(error(message), { status: 500 });
}
};
+34 -10
View File
@@ -6,6 +6,7 @@ import { registerSchema } from '$lib/utils/validators.js';
import { prisma } from '$lib/server/prisma.js';
import * as userService from '$lib/server/services/userService.js';
import * as groupService from '$lib/server/services/groupService.js';
import * as inviteService from '$lib/server/services/inviteService.js';
import { issueSessionCookies } from '$lib/server/utils/sessionCookies.js';
import { DEFAULTS } from '$lib/utils/constants.js';
@@ -17,26 +18,43 @@ async function isRegistrationEnabled(): Promise<boolean> {
return settings?.registrationEnabled ?? true;
}
export const load: PageServerLoad = async ({ locals }) => {
// If already logged in, redirect to home
export const load: PageServerLoad = async ({ locals, url }) => {
if (locals.user) {
throw redirect(302, '/');
}
const inviteToken = url.searchParams.get('invite');
const invite = inviteToken ? await inviteService.findInviteByToken(inviteToken) : null;
const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) {
if (!registrationEnabled && !invite) {
throw error(403, { message: 'Registration is currently disabled' });
}
const form = await superValidate(zod(registerSchema));
return { form, registrationEnabled };
if (invite?.email) {
form.data.email = invite.email;
}
return {
form,
registrationEnabled,
invite: invite
? { lockedEmail: invite.email, role: invite.role, expiresAt: invite.expiresAt }
: null,
inviteToken: invite ? inviteToken : null
};
};
export const actions: Actions = {
default: async (event) => {
const { request, cookies } = event;
const { request, cookies, url } = event;
const inviteToken = url.searchParams.get('invite');
const invite = inviteToken ? await inviteService.findInviteByToken(inviteToken) : null;
const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) {
if (!registrationEnabled && !invite) {
throw error(403, { message: 'Registration is currently disabled' });
}
@@ -48,24 +66,30 @@ export const actions: Actions = {
const { email, password, displayName } = form.data;
// Check email uniqueness
// If the invite locks an email, enforce it
if (invite?.email && invite.email.toLowerCase() !== email.toLowerCase()) {
return setError(form, 'email', 'This invite is locked to a different email');
}
const existingUser = await userService.findByEmail(email);
if (existingUser) {
return setError(form, 'email', 'An account with this email already exists');
}
// Create user
const user = await userService.create({
email,
password,
displayName,
authProvider: 'local',
role: 'user'
role: invite?.role === 'admin' ? 'admin' : 'user'
});
// Add user to default groups
await groupService.addUserToDefaultGroups(user.id);
if (invite) {
await inviteService.consumeInvite(invite.id, user.id);
}
await issueSessionCookies(cookies, user, { event });
throw redirect(302, '/');
+14 -1
View File
@@ -39,6 +39,18 @@
<p class="mt-1 text-sm text-muted-foreground">{$t('auth.register_subtitle')}</p>
</div>
{#if data.invite}
<div class="mb-4 rounded-lg border border-primary/40 bg-primary/10 p-3 text-xs text-foreground">
You have been invited to join
{#if data.invite.role === 'admin'}
as an <span class="font-semibold">administrator</span>
{/if}.
{#if data.invite.lockedEmail}
This invite is locked to <span class="font-mono">{data.invite.lockedEmail}</span>.
{/if}
</div>
{/if}
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="displayName" class="mb-1 block text-sm font-medium text-card-foreground">
@@ -67,8 +79,9 @@
name="email"
type="email"
autocomplete="email"
readonly={!!data.invite?.lockedEmail}
bind:value={$form.email}
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30"
class="w-full rounded-lg border border-input bg-background px-3 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:border-primary focus:outline-none focus:ring-2 focus:ring-ring/30 read-only:opacity-70"
placeholder={$t('auth.email_placeholder')}
/>
{#if $errors.email}