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:
@@ -0,0 +1,7 @@
|
||||
import type { LayoutServerLoad } from './$types.js';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals }) => {
|
||||
return {
|
||||
user: locals.user
|
||||
};
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
@@ -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 });
|
||||
}
|
||||
};
|
||||
@@ -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, '/');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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, '/');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user