From 38335e925b8317790377653ea637d08a93fd48e9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 16 Apr 2026 04:00:18 +0300 Subject: [PATCH] feat(auth): admin invite links 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. --- .../migration.sql | 21 ++ prisma/schema.prisma | 15 + src/lib/server/services/inviteService.ts | 100 +++++++ src/routes/admin/+layout.svelte | 1 + src/routes/admin/invites/+page.server.ts | 9 + src/routes/admin/invites/+page.svelte | 257 ++++++++++++++++++ src/routes/api/admin/invites/+server.ts | 60 ++++ src/routes/api/admin/invites/[id]/+server.ts | 19 ++ src/routes/register/+page.server.ts | 44 ++- src/routes/register/+page.svelte | 15 +- 10 files changed, 530 insertions(+), 11 deletions(-) create mode 100644 prisma/migrations/20260416010000_add_invite_model/migration.sql create mode 100644 src/lib/server/services/inviteService.ts create mode 100644 src/routes/admin/invites/+page.server.ts create mode 100644 src/routes/admin/invites/+page.svelte create mode 100644 src/routes/api/admin/invites/+server.ts create mode 100644 src/routes/api/admin/invites/[id]/+server.ts diff --git a/prisma/migrations/20260416010000_add_invite_model/migration.sql b/prisma/migrations/20260416010000_add_invite_model/migration.sql new file mode 100644 index 0000000..10db5f4 --- /dev/null +++ b/prisma/migrations/20260416010000_add_invite_model/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f8567d7..8effe83 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 diff --git a/src/lib/server/services/inviteService.ts b/src/lib/server/services/inviteService.ts new file mode 100644 index 0000000..11c4219 --- /dev/null +++ b/src/lib/server/services/inviteService.ts @@ -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 { + 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 { + 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 { + await prisma.invite.update({ + where: { id: inviteId }, + data: { usedAt: new Date(), usedByUserId: userId } + }); +} diff --git a/src/routes/admin/+layout.svelte b/src/routes/admin/+layout.svelte index bbf3034..2135200 100644 --- a/src/routes/admin/+layout.svelte +++ b/src/routes/admin/+layout.svelte @@ -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' }, diff --git a/src/routes/admin/invites/+page.server.ts b/src/routes/admin/invites/+page.server.ts new file mode 100644 index 0000000..1503466 --- /dev/null +++ b/src/routes/admin/invites/+page.server.ts @@ -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 }; +}; diff --git a/src/routes/admin/invites/+page.svelte b/src/routes/admin/invites/+page.svelte new file mode 100644 index 0000000..afea357 --- /dev/null +++ b/src/routes/admin/invites/+page.svelte @@ -0,0 +1,257 @@ + + + + Invites + + +
+
+
+

Invites

+

+ Generate invite links so people can register even when public registration is disabled. +

+
+ {#if !showForm} + + {/if} +
+ + {#if err} +
+ {err} +
+ {/if} + + {#if createdUrl} +
+

Invite created

+

+ Share this link with the recipient. It will only be shown once. +

+
+ + {createdUrl} + + +
+ +
+ {/if} + + {#if showForm} +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ {/if} + +
+ + + + + + + + + + + + + {#each data.invites as inv (inv.id)} + {@const status = inviteStatus(inv)} + + + + + + + + + {/each} + {#if data.invites.length === 0} + + + + {/if} + +
EmailRoleStatusExpiresCreated
{inv.email ?? '—'}{inv.role} + + {status} + + {fmt(inv.expiresAt)}{fmt(inv.createdAt)} + +
+ No invites yet. +
+
+
diff --git a/src/routes/api/admin/invites/+server.ts b/src/routes/api/admin/invites/+server.ts new file mode 100644 index 0000000..4da3183 --- /dev/null +++ b/src/routes/api/admin/invites/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/api/admin/invites/[id]/+server.ts b/src/routes/api/admin/invites/[id]/+server.ts new file mode 100644 index 0000000..4b6a0ca --- /dev/null +++ b/src/routes/api/admin/invites/[id]/+server.ts @@ -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 }); + } +}; diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts index 20c67b0..3593219 100644 --- a/src/routes/register/+page.server.ts +++ b/src/routes/register/+page.server.ts @@ -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 { 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, '/'); diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte index 1e9ac1b..e8b20ae 100644 --- a/src/routes/register/+page.svelte +++ b/src/routes/register/+page.svelte @@ -39,6 +39,18 @@

{$t('auth.register_subtitle')}

+ {#if data.invite} +
+ You have been invited to join + {#if data.invite.role === 'admin'} + as an administrator + {/if}. + {#if data.invite.lockedEmail} + This invite is locked to {data.invite.lockedEmail}. + {/if} +
+ {/if} +