feat: add auth pages (login, logout, register, change-password)

Login uses loginLimiter, authenticates via authenticateUser, creates
session cookie, and redirects foster role to /foster, others to /admin.
Logout destroys session and redirects to /. Register uses
registrationLimiter, validates input, creates inactive user, and sends
registration notification. Change-password verifies current password,
enforces 8-char minimum, updates hash, clears mustChangePassword flag.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:14:56 -05:00
co-authored by Claude Sonnet 4.6
parent fccc89630e
commit c83f24084c
7 changed files with 496 additions and 0 deletions
@@ -0,0 +1,53 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { hashPassword, verifyPassword } from '$lib/server/auth-utils';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) throw redirect(303, '/login');
if (!locals.user.mustChangePassword) {
throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin');
}
return {};
};
export const actions: Actions = {
default: async ({ request, locals }) => {
if (!locals.user) throw redirect(303, '/login');
const fd = await request.formData();
const currentPassword = fd.get('currentPassword')?.toString() ?? '';
const newPassword = fd.get('newPassword')?.toString() ?? '';
const confirmPassword = fd.get('confirmPassword')?.toString() ?? '';
if (!currentPassword || !newPassword || !confirmPassword) {
return fail(400, { error: 'All fields are required' });
}
if (newPassword.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters' });
}
if (newPassword !== confirmPassword) {
return fail(400, { error: 'New passwords do not match' });
}
const [user] = await db.select().from(users).where(eq(users.id, locals.user.id));
if (!user) throw redirect(303, '/login');
const valid = await verifyPassword(user.passwordHash, currentPassword);
if (!valid) {
return fail(400, { error: 'Current password is incorrect' });
}
const newHash = await hashPassword(newPassword);
await db.update(users).set({
passwordHash: newHash,
mustChangePassword: false
}).where(eq(users.id, locals.user.id));
throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin');
}
};
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
</script>
<svelte:head>
<title>Change Password — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</h1>
</div>
<form method="POST" use:enhance class="bg-white shadow-lg rounded-xl p-8 space-y-6">
<div class="text-center">
<h2 class="text-xl font-semibold text-gray-900">Change Your Password</h2>
<p class="text-gray-500 text-sm mt-1">You must change your password before continuing.</p>
</div>
{#if form?.error}
<div class="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<div>
<label for="currentPassword" class="block text-sm font-medium text-gray-700 mb-1">
Current Password
</label>
<input
type="password"
id="currentPassword"
name="currentPassword"
required
autocomplete="current-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<div>
<label for="newPassword" class="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
type="password"
id="newPassword"
name="newPassword"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
<p class="text-xs text-gray-500 mt-1">Must be at least 8 characters</p>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<button
type="submit"
class="w-full bg-teal-600 text-white py-2.5 rounded-lg font-medium hover:bg-teal-700 focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition"
>
Change Password
</button>
</form>
</div>
</div>
+62
View File
@@ -0,0 +1,62 @@
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { authenticateUser, createSession } from '$lib/server/auth';
import { db } from '$lib/server/db';
import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { loginLimiter } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin');
}
};
export const actions: Actions = {
default: async ({ request, cookies, getClientAddress }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!loginLimiter.check(ip)) {
return fail(429, { error: 'Too many login attempts. Please try again later.' });
}
const data = await request.formData();
const email = data.get('email')?.toString().trim();
const password = data.get('password')?.toString();
if (!email || !password) {
return fail(400, { error: 'Email and password are required', email });
}
const user = await authenticateUser(email, password);
if (!user) {
// Check if account exists but is inactive (pending approval)
if (email) {
const [existing] = await db
.select({ id: users.id, active: users.active })
.from(users)
.where(eq(users.email, email.toLowerCase()))
.limit(1);
if (existing && !existing.active) {
return fail(401, {
error: "Your account is pending approval. You'll receive an email when it's been reviewed.",
email
});
}
}
return fail(401, { error: 'Invalid email or password', email });
}
const token = await createSession(user.id);
cookies.set('session', token, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 30 * 24 * 60 * 60 // 30 days
});
throw redirect(303, user.role === 'foster' ? '/foster' : '/admin');
}
};
+67
View File
@@ -0,0 +1,67 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
</script>
<svelte:head>
<title>Login — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</h1>
<p class="text-gray-500 mt-2">Staff & Foster Portal</p>
</div>
<form method="POST" use:enhance class="bg-white shadow-lg rounded-xl p-8 space-y-6">
{#if form?.error}
<div class="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={form?.email ?? ''}
required
autocomplete="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<button
type="submit"
class="w-full bg-teal-600 text-white py-2.5 rounded-lg font-medium hover:bg-teal-700 focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition"
>
Sign In
</button>
<p class="text-center text-sm text-gray-500">
Need an account?
<a href="/register" class="text-teal-600 hover:text-teal-700 font-medium">Request access</a>
</p>
</form>
</div>
</div>
+14
View File
@@ -0,0 +1,14 @@
import type { Actions } from './$types';
import { redirect } from '@sveltejs/kit';
import { destroySession } from '$lib/server/auth';
export const actions: Actions = {
default: async ({ cookies, locals }) => {
const token = locals.sessionToken;
if (token) {
await destroySession(token);
}
cookies.delete('session', { path: '/' });
throw redirect(303, '/');
}
};
+81
View File
@@ -0,0 +1,81 @@
import type { Actions, PageServerLoad } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { users } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { hashPassword } from '$lib/server/auth-utils';
import { sendRegistrationNotification } from '$lib/server/email';
import { registrationLimiter } from '$lib/server/rate-limit';
export const load: PageServerLoad = async ({ locals }) => {
if (locals.user) {
throw redirect(303, locals.user.role === 'foster' ? '/foster' : '/admin');
}
};
export const actions: Actions = {
default: async ({ request, getClientAddress, locals }) => {
const ip = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? getClientAddress();
if (!registrationLimiter.check(ip)) {
return fail(429, { error: 'Too many registration attempts. Please try again later.' });
}
const data = await request.formData();
const name = data.get('name')?.toString().trim();
const email = data.get('email')?.toString().trim().toLowerCase();
const password = data.get('password')?.toString();
const confirmPassword = data.get('confirmPassword')?.toString();
const reason = data.get('reason')?.toString().trim();
if (!name || !email || !password || !confirmPassword || !reason) {
return fail(400, { error: 'All fields are required', name, email, reason });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return fail(400, { error: 'Please enter a valid email address', name, email, reason });
}
if (password.length < 8) {
return fail(400, { error: 'Password must be at least 8 characters', name, email, reason });
}
if (password !== confirmPassword) {
return fail(400, { error: 'Passwords do not match', name, email, reason });
}
const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)).limit(1);
if (existing) {
return fail(400, { error: 'An account with this email already exists', name, email, reason });
}
const passwordHash = await hashPassword(password);
await db.insert(users).values({
name,
email,
passwordHash,
role: 'viewer',
active: false,
mustChangePassword: false
});
const orgConfig = locals.orgConfig;
if (orgConfig.orgInfo.email) {
try {
await sendRegistrationNotification(
orgConfig.orgInfo.name,
orgConfig.orgInfo.email,
name,
email,
reason
);
} catch {
// Email failure shouldn't block registration
}
}
return { success: true };
}
};
+137
View File
@@ -0,0 +1,137 @@
<script lang="ts">
import { enhance } from '$app/forms';
let { data, form }: { data: any; form: Record<string, any> | null } = $props();
</script>
<svelte:head>
<title>Request Access — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</title>
</svelte:head>
<div class="min-h-screen flex items-center justify-center bg-gray-50 px-4 py-12">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}</h1>
<p class="text-gray-500 mt-2">Staff & Foster Portal</p>
</div>
{#if form?.success}
<div class="bg-white shadow-lg rounded-xl p-8">
<div class="bg-green-50 text-green-700 p-4 rounded-lg text-sm">
<div class="flex items-center gap-2 mb-2">
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
<span class="font-medium">Request Submitted</span>
</div>
<p>Your registration request has been submitted. You'll receive an email when your account is approved.</p>
</div>
<div class="mt-6 text-center">
<a href="/login" class="text-sm text-teal-600 hover:text-teal-700 font-medium">
Back to Sign In
</a>
</div>
</div>
{:else}
<form method="POST" use:enhance class="bg-white shadow-lg rounded-xl p-8 space-y-5">
<div>
<h2 class="text-xl font-semibold text-gray-900">Request Access</h2>
<p class="text-sm text-gray-500 mt-1">Submit a request to create an account. Your request will be reviewed by our team.</p>
</div>
{#if form?.error}
<div class="bg-red-50 text-red-700 p-3 rounded-lg text-sm">
{form.error}
</div>
{/if}
<div>
<label for="name" class="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
type="text"
id="name"
name="name"
value={form?.name ?? ''}
required
autocomplete="name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={form?.email ?? ''}
required
autocomplete="email"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
type="password"
id="password"
name="password"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
<p class="text-xs text-gray-400 mt-1">Minimum 8 characters</p>
</div>
<div>
<label for="confirmPassword" class="block text-sm font-medium text-gray-700 mb-1">
Confirm Password
</label>
<input
type="password"
id="confirmPassword"
name="confirmPassword"
required
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition"
/>
</div>
<div>
<label for="reason" class="block text-sm font-medium text-gray-700 mb-1">
Reason for access
</label>
<textarea
id="reason"
name="reason"
rows="3"
required
placeholder="Why do you need an account? e.g., I'm a foster, volunteer, staff member..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-teal-500 focus:border-teal-500 outline-none transition resize-none"
>{form?.reason ?? ''}</textarea>
</div>
<button
type="submit"
class="w-full bg-teal-600 text-white py-2.5 rounded-lg font-medium hover:bg-teal-700 focus:ring-2 focus:ring-teal-500 focus:ring-offset-2 transition"
>
Submit Request
</button>
<p class="text-center text-sm text-gray-500">
Already have an account?
<a href="/login" class="text-teal-600 hover:text-teal-700 font-medium">Sign in</a>
</p>
</form>
{/if}
</div>
</div>