feat(auth): add email verification to registration flow
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped
Some checks failed
CI / Test backend (pull_request) Failing after 11s
CI / Check ui (pull_request) Failing after 11s
CI / Docker / backend (pull_request) Has been skipped
CI / Docker / runner (pull_request) Has been skipped
CI / Docker / ui (pull_request) Has been skipped
CI / Docker / caddy (pull_request) Successful in 2m53s
Release / Test backend (push) Successful in 19s
Release / Check ui (push) Successful in 33s
Release / Docker / caddy (push) Failing after 1m26s
Release / Docker / ui (push) Failing after 11s
Release / Docker / backend (push) Successful in 2m2s
Release / Docker / runner (push) Successful in 2m24s
Release / Gitea Release (push) Has been skipped
- Add email/email_verified/verification_token/verification_token_exp fields
to app_users PocketBase schema (pb-init-v3.sh)
- Add SMTP env vars to UI service in docker-compose.yml
- New email.ts: raw TLS SMTP mailer via Node tls module, sendVerificationEmail()
- createUser() now takes email param, stores verification token (24h TTL)
- loginUser() throws 'Email not verified' when email_verified is false
- New /verify-email route: validates token, verifies user, auto-logs in
- Login page: email field in register form, check-inbox state after register
- /api/auth/register (iOS): returns { pending_verification, email } instead of token
- Add pb.libnovel.cc and storage.libnovel.cc Caddy virtual hosts for homelab runner
- Add homelab runner docker-compose and libnovel.sh helper script
This commit is contained in:
@@ -4,7 +4,7 @@ import { getSettings } from '$lib/server/pocketbase';
|
||||
import { log } from '$lib/server/logger';
|
||||
|
||||
// Routes that are accessible without being logged in
|
||||
const PUBLIC_ROUTES = new Set(['/login']);
|
||||
const PUBLIC_ROUTES = new Set(['/login', '/verify-email']);
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, url }) => {
|
||||
if (!PUBLIC_ROUTES.has(url.pathname) && !locals.user) {
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../../../hooks.server';
|
||||
import { createUser } from '$lib/server/pocketbase';
|
||||
import { sendVerificationEmail } from '$lib/server/email';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
/**
|
||||
* POST /api/auth/register
|
||||
* Body: { username: string, password: string }
|
||||
* Returns: { token: string, user: { id, username, role } }
|
||||
* Body: { username: string, email: string, password: string }
|
||||
* Returns: { pending_verification: true, email: string }
|
||||
*
|
||||
* Sets the libnovel_auth cookie and returns the raw token value so the
|
||||
* iOS app can persist it for subsequent requests.
|
||||
* Account is created but NOT activated until the user clicks the verification
|
||||
* link sent to their email. The iOS app should show a "check your inbox" screen.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
let body: { username?: string; password?: string };
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
let body: { username?: string; email?: string; password?: string };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -25,10 +21,11 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
}
|
||||
|
||||
const username = (body.username ?? '').trim();
|
||||
const email = (body.email ?? '').trim().toLowerCase();
|
||||
const password = body.password ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
error(400, 'Username and password are required');
|
||||
if (!username || !email || !password) {
|
||||
error(400, 'Username, email and password are required');
|
||||
}
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
error(400, 'Username must be between 3 and 32 characters');
|
||||
@@ -36,49 +33,34 @@ export const POST: RequestHandler = async ({ request, cookies, locals }) => {
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
error(400, 'Username may only contain letters, numbers, underscores and hyphens');
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
error(400, 'Please enter a valid email address');
|
||||
}
|
||||
if (password.length < 8) {
|
||||
error(400, 'Password must be at least 8 characters');
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser(username, password);
|
||||
user = await createUser(username, password, email);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : 'Registration failed.';
|
||||
if (msg.includes('Username already taken')) {
|
||||
error(409, 'That username is already taken');
|
||||
}
|
||||
if (msg.includes('Email already in use')) {
|
||||
error(409, 'That email address is already registered');
|
||||
}
|
||||
log.error('api/auth/register', 'unexpected error', { username, err: String(e) });
|
||||
error(500, 'An error occurred. Please try again.');
|
||||
}
|
||||
|
||||
// Merge anonymous session progress (non-fatal)
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((e) =>
|
||||
log.warn('api/auth/register', 'mergeSessionProgress failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
// Send verification email (non-fatal)
|
||||
try {
|
||||
await sendVerificationEmail(email, user.verification_token!);
|
||||
} catch (e) {
|
||||
log.error('api/auth/register', 'failed to send verification email', { username, email, err: String(e) });
|
||||
}
|
||||
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('api/auth/register', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
return json({
|
||||
token,
|
||||
user: { id: user.id, username: user.username, role: user.role ?? 'user' }
|
||||
});
|
||||
return json({ pending_verification: true, email });
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { loginUser, createUser, mergeSessionProgress, createUserSession } from '$lib/server/pocketbase';
|
||||
import { sendVerificationEmail } from '$lib/server/email';
|
||||
import { createAuthToken } from '../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
@@ -30,6 +31,13 @@ export const actions: Actions = {
|
||||
try {
|
||||
user = await loginUser(username, password);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (msg === 'Email not verified') {
|
||||
return fail(403, {
|
||||
action: 'login',
|
||||
error: 'Please verify your email before signing in. Check your inbox for the verification link.'
|
||||
});
|
||||
}
|
||||
log.error('auth', 'login unexpected error', { username, err: String(err) });
|
||||
return fail(500, { action: 'login', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
@@ -68,14 +76,15 @@ export const actions: Actions = {
|
||||
redirect(302, '/');
|
||||
},
|
||||
|
||||
register: async ({ request, cookies, locals }) => {
|
||||
register: async ({ request }) => {
|
||||
const data = await request.formData();
|
||||
const username = (data.get('username') as string | null)?.trim() ?? '';
|
||||
const email = (data.get('email') as string | null)?.trim().toLowerCase() ?? '';
|
||||
const password = (data.get('password') as string | null) ?? '';
|
||||
const confirm = (data.get('confirm') as string | null) ?? '';
|
||||
|
||||
if (!username || !password) {
|
||||
return fail(400, { action: 'register', error: 'Username and password are required.' });
|
||||
if (!username || !email || !password) {
|
||||
return fail(400, { action: 'register', error: 'All fields are required.' });
|
||||
}
|
||||
if (username.length < 3 || username.length > 32) {
|
||||
return fail(400, {
|
||||
@@ -89,6 +98,9 @@ export const actions: Actions = {
|
||||
error: 'Username may only contain letters, numbers, underscores and hyphens.'
|
||||
});
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
return fail(400, { action: 'register', error: 'Please enter a valid email address.' });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, {
|
||||
action: 'register',
|
||||
@@ -101,42 +113,28 @@ export const actions: Actions = {
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await createUser(username, password);
|
||||
user = await createUser(username, password, email);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : 'Registration failed.';
|
||||
if (msg.includes('Username already taken')) {
|
||||
return fail(409, { action: 'register', error: 'That username is already taken.' });
|
||||
}
|
||||
if (msg.includes('Email already in use')) {
|
||||
return fail(409, { action: 'register', error: 'That email address is already registered.' });
|
||||
}
|
||||
log.error('auth', 'register unexpected error', { username, err: String(err) });
|
||||
return fail(500, { action: 'register', error: 'An error occurred. Please try again.' });
|
||||
}
|
||||
|
||||
// Merge any anonymous session progress into the newly created account.
|
||||
mergeSessionProgress(locals.sessionId, user.id).catch((err) =>
|
||||
log.warn('auth', 'register: mergeSessionProgress failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
// Send verification email (non-fatal — user can re-request later)
|
||||
try {
|
||||
await sendVerificationEmail(email, user.verification_token!);
|
||||
} catch (err) {
|
||||
log.error('auth', 'register: failed to send verification email', { username, email, err: String(err) });
|
||||
// Don't fail registration if email fails — user sees the pending screen
|
||||
}
|
||||
|
||||
// Create a unique auth session ID for this registration
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
|
||||
// Record the session in PocketBase (best-effort, non-fatal)
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((err) =>
|
||||
log.warn('auth', 'register: createUserSession failed (non-fatal)', { err: String(err) })
|
||||
);
|
||||
|
||||
const token = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
cookies.set(AUTH_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
redirect(302, '/');
|
||||
// Return success state — do NOT log the user in yet
|
||||
return { action: 'register', registered: true, email };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
|
||||
let { form }: { form: ActionData } = $props();
|
||||
|
||||
// Cast to access union members that TypeScript can't narrow statically
|
||||
const f = $derived(form as (typeof form) & { registered?: boolean; email?: string } | null);
|
||||
|
||||
let mode: 'login' | 'register' = $state('login');
|
||||
</script>
|
||||
|
||||
@@ -12,125 +15,156 @@
|
||||
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm">
|
||||
<!-- Tab switcher -->
|
||||
<div class="flex mb-6 border-b border-zinc-700">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'login')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'login'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (mode = 'register')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'register'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if form?.error && (form?.action === mode || !form?.action)}
|
||||
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
||||
{form.error}
|
||||
<!-- Post-registration: check inbox -->
|
||||
{#if f?.registered}
|
||||
<div class="text-center">
|
||||
<div class="mb-4 text-4xl">✉️</div>
|
||||
<h2 class="text-lg font-semibold text-zinc-100 mb-2">Check your inbox</h2>
|
||||
<p class="text-sm text-zinc-400 mb-6">
|
||||
We sent a verification link to <span class="text-zinc-200 font-medium">{f?.email}</span>.
|
||||
Click it to activate your account.
|
||||
</p>
|
||||
<p class="text-xs text-zinc-500">
|
||||
Didn't receive it? Check your spam folder, or
|
||||
<a href="/login" class="text-amber-400 hover:text-amber-300 transition-colors">try again</a>.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'login'}
|
||||
<form method="POST" action="?/login" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Tab switcher -->
|
||||
<div class="flex mb-6 border-b border-zinc-700">
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
type="button"
|
||||
onclick={() => (mode = 'login')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'login'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/register" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
pattern="[a-zA-Z0-9_\-]+"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">3–32 characters: letters, numbers, _ or -</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
|
||||
<input
|
||||
id="reg-confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
type="button"
|
||||
onclick={() => (mode = 'register')}
|
||||
class="flex-1 pb-3 text-sm font-medium transition-colors
|
||||
{mode === 'register'
|
||||
? 'text-amber-400 border-b-2 border-amber-400 -mb-px'
|
||||
: 'text-zinc-400 hover:text-zinc-100'}"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if form?.error && (form?.action === mode || !form?.action)}
|
||||
<div class="mb-4 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if mode === 'login'}
|
||||
<form method="POST" action="?/login" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="login-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="login-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="login-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="login-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/register" class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="reg-username" class="block text-xs text-zinc-400 mb-1">Username</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
name="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
required
|
||||
minlength="3"
|
||||
maxlength="32"
|
||||
pattern="[a-zA-Z0-9_\-]+"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="your_username"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">3–32 characters: letters, numbers, _ or -</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-email" class="block text-xs text-zinc-400 mb-1">Email</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
name="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="you@example.com"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">Used to verify your account — not shown publicly</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-password" class="block text-xs text-zinc-400 mb-1">Password</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
name="password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
minlength="8"
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-zinc-500">At least 8 characters</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="reg-confirm" class="block text-xs text-zinc-400 mb-1">Confirm password</label>
|
||||
<input
|
||||
id="reg-confirm"
|
||||
name="confirm"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
class="w-full rounded bg-zinc-800 border border-zinc-700 px-3 py-2 text-sm text-zinc-100
|
||||
placeholder-zinc-500 focus:outline-none focus:border-amber-400 focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="w-full py-2 rounded bg-amber-400 text-zinc-900 font-semibold text-sm hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Create account
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
ui/src/routes/verify-email/+page.server.ts
Normal file
72
ui/src/routes/verify-email/+page.server.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import {
|
||||
getUserByVerificationToken,
|
||||
verifyUserEmail,
|
||||
createUserSession
|
||||
} from '$lib/server/pocketbase';
|
||||
import { createAuthToken } from '../../hooks.server';
|
||||
import { log } from '$lib/server/logger';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
const ONE_YEAR = 60 * 60 * 24 * 365;
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, request }) => {
|
||||
const token = url.searchParams.get('token') ?? '';
|
||||
|
||||
if (!token) {
|
||||
return { success: false, error: 'Missing verification token.' };
|
||||
}
|
||||
|
||||
let user;
|
||||
try {
|
||||
user = await getUserByVerificationToken(token);
|
||||
} catch (e) {
|
||||
log.error('verify-email', 'lookup failed', { err: String(e) });
|
||||
return { success: false, error: 'An error occurred. Please try again.' };
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return { success: false, error: 'Invalid or expired verification link.' };
|
||||
}
|
||||
|
||||
// Check expiry
|
||||
if (user.verification_token_exp) {
|
||||
const exp = new Date(user.verification_token_exp).getTime();
|
||||
if (Date.now() > exp) {
|
||||
return { success: false, error: 'This verification link has expired. Please register again.' };
|
||||
}
|
||||
}
|
||||
|
||||
// Mark email as verified
|
||||
try {
|
||||
await verifyUserEmail(user.id);
|
||||
} catch (e) {
|
||||
log.error('verify-email', 'verifyUserEmail failed', { userId: user.id, err: String(e) });
|
||||
return { success: false, error: 'Failed to verify email. Please try again.' };
|
||||
}
|
||||
|
||||
// Log the user in automatically
|
||||
const authSessionId = randomBytes(16).toString('hex');
|
||||
const userAgent = request.headers.get('user-agent') ?? '';
|
||||
const ip =
|
||||
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
|
||||
request.headers.get('x-real-ip') ??
|
||||
'';
|
||||
|
||||
createUserSession(user.id, authSessionId, userAgent, ip).catch((e) =>
|
||||
log.warn('verify-email', 'createUserSession failed (non-fatal)', { err: String(e) })
|
||||
);
|
||||
|
||||
const authToken = createAuthToken(user.id, user.username, user.role ?? 'user', authSessionId);
|
||||
cookies.set(AUTH_COOKIE, authToken, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
maxAge: ONE_YEAR
|
||||
});
|
||||
|
||||
log.info('verify-email', 'email verified, user logged in', { userId: user.id, username: user.username });
|
||||
redirect(302, '/');
|
||||
};
|
||||
21
ui/src/routes/verify-email/+page.svelte
Normal file
21
ui/src/routes/verify-email/+page.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Verify email — libnovel</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-center min-h-[60vh]">
|
||||
<div class="w-full max-w-sm text-center">
|
||||
{#if data.error}
|
||||
<div class="mb-6 rounded bg-red-900/40 border border-red-700 px-4 py-3 text-sm text-red-300">
|
||||
{data.error}
|
||||
</div>
|
||||
<a href="/login" class="text-sm text-amber-400 hover:text-amber-300 transition-colors">
|
||||
Back to sign in
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user