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.
This commit is contained in:
@@ -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");
|
||||||
@@ -41,6 +41,21 @@ model User {
|
|||||||
@@index([email])
|
@@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 {
|
model Session {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
userId String
|
userId String
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
const navItems = $derived([
|
const navItems = $derived([
|
||||||
{ href: '/admin/users', labelKey: 'admin.users' },
|
{ href: '/admin/users', labelKey: 'admin.users' },
|
||||||
|
{ href: '/admin/invites', label: 'Invites' },
|
||||||
{ href: '/admin/groups', labelKey: 'admin.groups' },
|
{ href: '/admin/groups', labelKey: 'admin.groups' },
|
||||||
{ href: '/admin/tags', label: 'Tags' },
|
{ href: '/admin/tags', label: 'Tags' },
|
||||||
{ href: '/admin/audit-log', label: 'Audit Log' },
|
{ href: '/admin/audit-log', label: 'Audit Log' },
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -6,6 +6,7 @@ import { registerSchema } from '$lib/utils/validators.js';
|
|||||||
import { prisma } from '$lib/server/prisma.js';
|
import { prisma } from '$lib/server/prisma.js';
|
||||||
import * as userService from '$lib/server/services/userService.js';
|
import * as userService from '$lib/server/services/userService.js';
|
||||||
import * as groupService from '$lib/server/services/groupService.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 { issueSessionCookies } from '$lib/server/utils/sessionCookies.js';
|
||||||
import { DEFAULTS } from '$lib/utils/constants.js';
|
import { DEFAULTS } from '$lib/utils/constants.js';
|
||||||
|
|
||||||
@@ -17,26 +18,43 @@ async function isRegistrationEnabled(): Promise<boolean> {
|
|||||||
return settings?.registrationEnabled ?? true;
|
return settings?.registrationEnabled ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ locals }) => {
|
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||||
// If already logged in, redirect to home
|
|
||||||
if (locals.user) {
|
if (locals.user) {
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const inviteToken = url.searchParams.get('invite');
|
||||||
|
const invite = inviteToken ? await inviteService.findInviteByToken(inviteToken) : null;
|
||||||
|
|
||||||
const registrationEnabled = await isRegistrationEnabled();
|
const registrationEnabled = await isRegistrationEnabled();
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled && !invite) {
|
||||||
throw error(403, { message: 'Registration is currently disabled' });
|
throw error(403, { message: 'Registration is currently disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const form = await superValidate(zod(registerSchema));
|
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 = {
|
export const actions: Actions = {
|
||||||
default: async (event) => {
|
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();
|
const registrationEnabled = await isRegistrationEnabled();
|
||||||
if (!registrationEnabled) {
|
if (!registrationEnabled && !invite) {
|
||||||
throw error(403, { message: 'Registration is currently disabled' });
|
throw error(403, { message: 'Registration is currently disabled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,24 +66,30 @@ export const actions: Actions = {
|
|||||||
|
|
||||||
const { email, password, displayName } = form.data;
|
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);
|
const existingUser = await userService.findByEmail(email);
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
return setError(form, 'email', 'An account with this email already exists');
|
return setError(form, 'email', 'An account with this email already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create user
|
|
||||||
const user = await userService.create({
|
const user = await userService.create({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
displayName,
|
displayName,
|
||||||
authProvider: 'local',
|
authProvider: 'local',
|
||||||
role: 'user'
|
role: invite?.role === 'admin' ? 'admin' : 'user'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add user to default groups
|
|
||||||
await groupService.addUserToDefaultGroups(user.id);
|
await groupService.addUserToDefaultGroups(user.id);
|
||||||
|
|
||||||
|
if (invite) {
|
||||||
|
await inviteService.consumeInvite(invite.id, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
await issueSessionCookies(cookies, user, { event });
|
await issueSessionCookies(cookies, user, { event });
|
||||||
|
|
||||||
throw redirect(302, '/');
|
throw redirect(302, '/');
|
||||||
|
|||||||
@@ -39,6 +39,18 @@
|
|||||||
<p class="mt-1 text-sm text-muted-foreground">{$t('auth.register_subtitle')}</p>
|
<p class="mt-1 text-sm text-muted-foreground">{$t('auth.register_subtitle')}</p>
|
||||||
</div>
|
</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">
|
<form method="POST" use:enhance class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label for="displayName" class="mb-1 block text-sm font-medium text-card-foreground">
|
<label for="displayName" class="mb-1 block text-sm font-medium text-card-foreground">
|
||||||
@@ -67,8 +79,9 @@
|
|||||||
name="email"
|
name="email"
|
||||||
type="email"
|
type="email"
|
||||||
autocomplete="email"
|
autocomplete="email"
|
||||||
|
readonly={!!data.invite?.lockedEmail}
|
||||||
bind:value={$form.email}
|
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')}
|
placeholder={$t('auth.email_placeholder')}
|
||||||
/>
|
/>
|
||||||
{#if $errors.email}
|
{#if $errors.email}
|
||||||
|
|||||||
Reference in New Issue
Block a user