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

- 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:
Admin
2026-03-24 20:18:24 +05:00
parent 424f2c5e16
commit 920ac0d41b
13 changed files with 759 additions and 211 deletions

View File

@@ -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 });
};