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,117 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { verifyAccessToken } from '$lib/server/services/authService.js';
|
||||
import * as authService from '$lib/server/services/authService.js';
|
||||
import * as userService from '$lib/server/services/userService.js';
|
||||
import { isBoardGuestAccessible } from '$lib/server/middleware/guestAccess.js';
|
||||
|
||||
const PUBLIC_PATHS = ['/login', '/register', '/auth/', '/api/health'];
|
||||
|
||||
function isPublicPath(pathname: string): boolean {
|
||||
return PUBLIC_PATHS.some((path) => pathname === path || pathname.startsWith(path));
|
||||
}
|
||||
|
||||
const ACCESS_TOKEN_COOKIE = 'access_token';
|
||||
const REFRESH_TOKEN_COOKIE = 'refresh_token';
|
||||
|
||||
const COOKIE_BASE = {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
path: '/'
|
||||
};
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
// Initialize locals
|
||||
event.locals.user = null;
|
||||
event.locals.session = null;
|
||||
|
||||
const accessToken = event.cookies.get(ACCESS_TOKEN_COOKIE);
|
||||
const refreshToken = event.cookies.get(REFRESH_TOKEN_COOKIE);
|
||||
|
||||
if (accessToken) {
|
||||
try {
|
||||
const payload = verifyAccessToken(accessToken);
|
||||
const user = await userService.findById(payload.userId);
|
||||
event.locals.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role as 'admin' | 'user'
|
||||
};
|
||||
event.locals.session = {
|
||||
id: payload.userId,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
||||
};
|
||||
} catch {
|
||||
// Access token invalid/expired — try refresh below
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid session but refresh token exists, attempt rotation
|
||||
if (!event.locals.user && refreshToken) {
|
||||
try {
|
||||
// We need to find the user by refresh token.
|
||||
// The refresh token is stored hashed per-user, so we need
|
||||
// a userId from somewhere. We store it in a separate cookie.
|
||||
const userIdFromCookie = event.cookies.get('refresh_user_id');
|
||||
if (userIdFromCookie) {
|
||||
const isValid = await authService.validateRefreshToken(userIdFromCookie, refreshToken);
|
||||
if (isValid) {
|
||||
const user = await userService.findById(userIdFromCookie);
|
||||
const tokens = await authService.rotateTokens(user.id, user.email, user.role);
|
||||
|
||||
// Set new cookies
|
||||
event.cookies.set(ACCESS_TOKEN_COOKIE, tokens.accessToken, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 900 // 15 minutes
|
||||
});
|
||||
event.cookies.set(REFRESH_TOKEN_COOKIE, tokens.refreshToken, {
|
||||
...COOKIE_BASE,
|
||||
maxAge: 604800 // 7 days
|
||||
});
|
||||
|
||||
event.locals.user = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
role: user.role as 'admin' | 'user'
|
||||
};
|
||||
event.locals.session = {
|
||||
id: user.id,
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000)
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Refresh failed — clear stale cookies
|
||||
event.cookies.delete(ACCESS_TOKEN_COOKIE, { path: '/' });
|
||||
event.cookies.delete(REFRESH_TOKEN_COOKIE, { path: '/' });
|
||||
event.cookies.delete('refresh_user_id', { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
// Route protection
|
||||
const { pathname } = event.url;
|
||||
|
||||
if (!event.locals.user && !isPublicPath(pathname)) {
|
||||
// Check if this is a guest-accessible board route
|
||||
const boardMatch = pathname.match(/^\/boards\/([^/]+)/);
|
||||
if (boardMatch) {
|
||||
const boardId = boardMatch[1];
|
||||
const isGuestAccessible = await isBoardGuestAccessible(boardId);
|
||||
if (isGuestAccessible) {
|
||||
return resolve(event);
|
||||
}
|
||||
}
|
||||
|
||||
// Root path — allow through so +page.server.ts can handle redirect logic
|
||||
if (pathname === '/') {
|
||||
return resolve(event);
|
||||
}
|
||||
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
/**
|
||||
* Reusable authentication check helper.
|
||||
* Throws a redirect to /login if the user is not authenticated.
|
||||
* Returns the authenticated user from event.locals.
|
||||
*/
|
||||
export function requireAuth(event: RequestEvent) {
|
||||
const user = event.locals.user;
|
||||
if (!user) {
|
||||
throw redirect(302, '/login');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the current request has an authenticated user without redirecting.
|
||||
*/
|
||||
export function isAuthenticated(event: RequestEvent): boolean {
|
||||
return event.locals.user !== null;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { requireAuth } from './authenticate.js';
|
||||
import { UserRole } from '$lib/utils/constants.js';
|
||||
|
||||
/**
|
||||
* Role-based access check. Ensures the user is authenticated and has one of the required roles.
|
||||
* Throws a 403 error if the user's role is not in the allowed list.
|
||||
*/
|
||||
export function requireRole(event: RequestEvent, ...allowedRoles: string[]) {
|
||||
const user = requireAuth(event);
|
||||
|
||||
if (!allowedRoles.includes(user.role)) {
|
||||
throw error(403, { message: 'Insufficient permissions' });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand: require admin role.
|
||||
*/
|
||||
export function requireAdmin(event: RequestEvent) {
|
||||
return requireRole(event, UserRole.ADMIN);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { prisma } from '../prisma.js';
|
||||
|
||||
/**
|
||||
* Check if a board is guest-accessible (visible to unauthenticated users).
|
||||
*/
|
||||
export async function isBoardGuestAccessible(boardId: string): Promise<boolean> {
|
||||
const board = await prisma.board.findUnique({
|
||||
where: { id: boardId },
|
||||
select: { isGuestAccessible: true }
|
||||
});
|
||||
return board?.isGuestAccessible ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all guest-accessible boards.
|
||||
*/
|
||||
export async function getGuestAccessibleBoards() {
|
||||
return prisma.board.findMany({
|
||||
where: { isGuestAccessible: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
icon: true,
|
||||
description: true,
|
||||
isDefault: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default guest-accessible board (if any).
|
||||
* Returns the first board that is both default and guest-accessible,
|
||||
* or the first guest-accessible board if none is default.
|
||||
*/
|
||||
export async function getDefaultGuestBoard() {
|
||||
const defaultBoard = await prisma.board.findFirst({
|
||||
where: { isGuestAccessible: true, isDefault: true },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
|
||||
if (defaultBoard) return defaultBoard;
|
||||
|
||||
return prisma.board.findFirst({
|
||||
where: { isGuestAccessible: true },
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* JWT utilities — thin re-exports from authService.
|
||||
* authService already handles sign, verify, and refresh token generation.
|
||||
*/
|
||||
export {
|
||||
signAccessToken,
|
||||
verifyAccessToken,
|
||||
generateRefreshToken,
|
||||
getRefreshTokenExpiry
|
||||
} from '../services/authService.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Password utilities — thin re-exports from authService.
|
||||
* authService already handles bcrypt hash and compare.
|
||||
*/
|
||||
export { hashPassword, verifyPassword } from '../services/authService.js';
|
||||
@@ -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