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:
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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');
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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, '/');
|
||||
}
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user