feat(mvp): phase 3 - authentication system

Implement local auth flow: login, registration, logout, JWT access/refresh
tokens in HTTP-only cookies, hooks.server.ts middleware, guest mode support,
Superforms + Zod validation, and reusable auth/authorize middleware.
This commit is contained in:
2026-03-24 20:45:14 +03:00
parent f1b1aa5975
commit 2c001df322
19 changed files with 751 additions and 28 deletions
+7
View File
@@ -0,0 +1,7 @@
import type { LayoutServerLoad } from './$types.js';
export const load: LayoutServerLoad = async ({ locals }) => {
return {
user: locals.user
};
};
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import '../app.css';
import type { Snippet } from 'svelte';
import type { LayoutData } from './$types.js';
let { data, children }: { data: LayoutData; children: Snippet } = $props();
</script>
<div class="dark min-h-screen">
{@render children()}
</div>
+30
View File
@@ -0,0 +1,30 @@
import type { PageServerLoad } from './$types.js';
import { redirect } from '@sveltejs/kit';
import { prisma } from '$lib/server/prisma.js';
import { getDefaultGuestBoard } from '$lib/server/middleware/guestAccess.js';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
// Authenticated user: redirect to their default board
const defaultBoard = await prisma.board.findFirst({
where: { isDefault: true },
select: { id: true }
});
if (defaultBoard) {
throw redirect(302, `/boards/${defaultBoard.id}`);
}
// No default board — stay on root page
return { user: locals.user };
}
// Unauthenticated: check for guest-accessible board
const guestBoard = await getDefaultGuestBoard();
if (guestBoard) {
throw redirect(302, `/boards/${guestBoard.id}`);
}
// No guest board available — redirect to login
throw redirect(302, '/login');
};
+19 -2
View File
@@ -1,5 +1,7 @@
<script lang="ts">
// Placeholder — replaced in Phase 5 with the board layout
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
</script>
<svelte:head>
@@ -7,5 +9,20 @@
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<h1 class="text-4xl font-bold">Web App Launcher</h1>
<div class="text-center">
<h1 class="text-4xl font-bold">Web App Launcher</h1>
{#if data.user}
<p class="mt-4 text-muted-foreground">
Welcome, {data.user.displayName}. No default board is configured yet.
</p>
<form method="POST" action="/auth/logout" class="mt-6">
<button
type="submit"
class="rounded-md bg-secondary px-4 py-2 text-sm font-medium text-secondary-foreground hover:bg-secondary/80"
>
Sign Out
</button>
</form>
{/if}
</div>
</main>
+21
View File
@@ -0,0 +1,21 @@
import { redirect } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import * as authService from '$lib/server/services/authService.js';
export const POST: RequestHandler = async ({ cookies, locals }) => {
// Revoke refresh token in database
if (locals.user) {
try {
await authService.revokeRefreshToken(locals.user.id);
} catch {
// Best-effort revocation — continue with cookie cleanup
}
}
// Clear all auth cookies
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
cookies.delete('refresh_user_id', { path: '/' });
throw redirect(302, '/login');
};
+55
View File
@@ -0,0 +1,55 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import * as authService from '$lib/server/services/authService.js';
import * as userService from '$lib/server/services/userService.js';
import { error as apiError } from '$lib/server/utils/response.js';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
export const POST: RequestHandler = async ({ cookies }) => {
const refreshToken = cookies.get('refresh_token');
const userId = cookies.get('refresh_user_id');
if (!refreshToken || !userId) {
return json(apiError('No refresh token provided'), { status: 401 });
}
try {
const isValid = await authService.validateRefreshToken(userId, refreshToken);
if (!isValid) {
// Clear stale cookies
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
cookies.delete('refresh_user_id', { path: '/' });
return json(apiError('Invalid or expired refresh token'), { status: 401 });
}
const user = await userService.findById(userId);
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
cookies.set('access_token', tokens.accessToken, {
...COOKIE_BASE,
maxAge: 900
});
cookies.set('refresh_token', tokens.refreshToken, {
...COOKIE_BASE,
maxAge: 604800
});
return json({
success: true,
data: { expiresIn: 900 },
error: null
});
} catch {
cookies.delete('access_token', { path: '/' });
cookies.delete('refresh_token', { path: '/' });
cookies.delete('refresh_user_id', { path: '/' });
return json(apiError('Token refresh failed'), { status: 401 });
}
};
+77
View File
@@ -0,0 +1,77 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail, redirect } from '@sveltejs/kit';
import { loginSchema } from '$lib/utils/validators.js';
import * as userService from '$lib/server/services/userService.js';
import * as authService from '$lib/server/services/authService.js';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
export const load: PageServerLoad = async ({ locals }) => {
// If already logged in, redirect to home
if (locals.user) {
throw redirect(302, '/');
}
const form = await superValidate(zod(loginSchema));
return { form };
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const form = await superValidate(request, zod(loginSchema));
if (!form.valid) {
return fail(400, { form });
}
const { email, password } = form.data;
// Find user by email
const user = await userService.findByEmail(email);
if (!user) {
return setError(form, 'email', 'Invalid email or password');
}
// Verify password
if (!user.password) {
return setError(form, 'email', 'This account does not use password authentication');
}
const passwordValid = await authService.verifyPassword(password, user.password);
if (!passwordValid) {
return setError(form, 'email', 'Invalid email or password');
}
// Generate tokens
const accessToken = authService.signAccessToken({
userId: user.id,
email: user.email,
role: user.role
});
const refreshToken = authService.generateRefreshToken();
await authService.saveRefreshToken(user.id, refreshToken);
// Set cookies
cookies.set('access_token', accessToken, {
...COOKIE_BASE,
maxAge: 900 // 15 minutes
});
cookies.set('refresh_token', refreshToken, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
cookies.set('refresh_user_id', user.id, {
...COOKIE_BASE,
maxAge: 604800 // 7 days
});
throw redirect(302, '/');
}
};
+73
View File
@@ -0,0 +1,73 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
</script>
<svelte:head>
<title>Login — Web App Launcher</title>
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<div class="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-lg">
<h1 class="mb-6 text-center text-2xl font-bold text-card-foreground">Sign In</h1>
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="you@example.com"
/>
{#if $errors.email}
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="current-password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Enter your password"
/>
{#if $errors.password}
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
{/if}
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
Signing in...
{:else}
Sign In
{/if}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
Don't have an account?
<a href="/register" class="font-medium text-primary hover:underline">Register</a>
</p>
</div>
</main>
+100
View File
@@ -0,0 +1,100 @@
import type { Actions, PageServerLoad } from './$types.js';
import { superValidate, setError } from 'sveltekit-superforms';
import { zod } from 'sveltekit-superforms/adapters';
import { fail, redirect, error } from '@sveltejs/kit';
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 authService from '$lib/server/services/authService.js';
import * as groupService from '$lib/server/services/groupService.js';
import { DEFAULTS } from '$lib/utils/constants.js';
const COOKIE_BASE = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/'
};
async function isRegistrationEnabled(): Promise<boolean> {
const settings = await prisma.systemSettings.findUnique({
where: { id: DEFAULTS.SYSTEM_SETTINGS_ID },
select: { registrationEnabled: true }
});
return settings?.registrationEnabled ?? true;
}
export const load: PageServerLoad = async ({ locals }) => {
// If already logged in, redirect to home
if (locals.user) {
throw redirect(302, '/');
}
const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) {
throw error(403, { message: 'Registration is currently disabled' });
}
const form = await superValidate(zod(registerSchema));
return { form, registrationEnabled };
};
export const actions: Actions = {
default: async ({ request, cookies }) => {
const registrationEnabled = await isRegistrationEnabled();
if (!registrationEnabled) {
throw error(403, { message: 'Registration is currently disabled' });
}
const form = await superValidate(request, zod(registerSchema));
if (!form.valid) {
return fail(400, { form });
}
const { email, password, displayName } = form.data;
// Check email uniqueness
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'
});
// Add user to default groups
await groupService.addUserToDefaultGroups(user.id);
// Auto-login: generate tokens
const accessToken = authService.signAccessToken({
userId: user.id,
email: user.email,
role: user.role
});
const refreshToken = authService.generateRefreshToken();
await authService.saveRefreshToken(user.id, refreshToken);
// Set cookies
cookies.set('access_token', accessToken, {
...COOKIE_BASE,
maxAge: 900
});
cookies.set('refresh_token', refreshToken, {
...COOKIE_BASE,
maxAge: 604800
});
cookies.set('refresh_user_id', user.id, {
...COOKIE_BASE,
maxAge: 604800
});
throw redirect(302, '/');
}
};
+91
View File
@@ -0,0 +1,91 @@
<script lang="ts">
import { superForm } from 'sveltekit-superforms';
import type { PageData } from './$types.js';
let { data }: { data: PageData } = $props();
const { form, errors, enhance, submitting } = superForm(data.form);
</script>
<svelte:head>
<title>Register — Web App Launcher</title>
</svelte:head>
<main class="flex min-h-screen items-center justify-center bg-background text-foreground">
<div class="w-full max-w-md rounded-lg border border-border bg-card p-8 shadow-lg">
<h1 class="mb-6 text-center text-2xl font-bold text-card-foreground">Create Account</h1>
<form method="POST" use:enhance class="space-y-4">
<div>
<label for="displayName" class="mb-1 block text-sm font-medium text-card-foreground">
Display Name
</label>
<input
id="displayName"
name="displayName"
type="text"
autocomplete="name"
bind:value={$form.displayName}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="Your name"
/>
{#if $errors.displayName}
<p class="mt-1 text-sm text-destructive">{$errors.displayName[0]}</p>
{/if}
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-card-foreground">
Email
</label>
<input
id="email"
name="email"
type="email"
autocomplete="email"
bind:value={$form.email}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="you@example.com"
/>
{#if $errors.email}
<p class="mt-1 text-sm text-destructive">{$errors.email[0]}</p>
{/if}
</div>
<div>
<label for="password" class="mb-1 block text-sm font-medium text-card-foreground">
Password
</label>
<input
id="password"
name="password"
type="password"
autocomplete="new-password"
bind:value={$form.password}
class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
placeholder="At least 6 characters"
/>
{#if $errors.password}
<p class="mt-1 text-sm text-destructive">{$errors.password[0]}</p>
{/if}
</div>
<button
type="submit"
disabled={$submitting}
class="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
>
{#if $submitting}
Creating account...
{:else}
Create Account
{/if}
</button>
</form>
<p class="mt-4 text-center text-sm text-muted-foreground">
Already have an account?
<a href="/login" class="font-medium text-primary hover:underline">Sign in</a>
</p>
</div>
</main>