diff --git a/src/routes/change-password/+page.server.ts b/src/routes/change-password/+page.server.ts new file mode 100644 index 0000000..96d7411 --- /dev/null +++ b/src/routes/change-password/+page.server.ts @@ -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'); + } +}; diff --git a/src/routes/change-password/+page.svelte b/src/routes/change-password/+page.svelte new file mode 100644 index 0000000..17b92d5 --- /dev/null +++ b/src/routes/change-password/+page.svelte @@ -0,0 +1,82 @@ + + + + Change Password — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
+
+
+

{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

+
+ +
+
+

Change Your Password

+

You must change your password before continuing.

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + +
+ +
+ + +

Must be at least 8 characters

+
+ +
+ + +
+ + +
+
+
diff --git a/src/routes/login/+page.server.ts b/src/routes/login/+page.server.ts new file mode 100644 index 0000000..04116e3 --- /dev/null +++ b/src/routes/login/+page.server.ts @@ -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'); + } +}; diff --git a/src/routes/login/+page.svelte b/src/routes/login/+page.svelte new file mode 100644 index 0000000..b6ab5f5 --- /dev/null +++ b/src/routes/login/+page.svelte @@ -0,0 +1,67 @@ + + + + Login — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
+
+
+

{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

+

Staff & Foster Portal

+
+ +
+ {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + +
+ +
+ + +
+ + + +

+ Need an account? + Request access +

+
+
+
diff --git a/src/routes/logout/+page.server.ts b/src/routes/logout/+page.server.ts new file mode 100644 index 0000000..86799df --- /dev/null +++ b/src/routes/logout/+page.server.ts @@ -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, '/'); + } +}; diff --git a/src/routes/register/+page.server.ts b/src/routes/register/+page.server.ts new file mode 100644 index 0000000..0548109 --- /dev/null +++ b/src/routes/register/+page.server.ts @@ -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 }; + } +}; diff --git a/src/routes/register/+page.svelte b/src/routes/register/+page.svelte new file mode 100644 index 0000000..897f80a --- /dev/null +++ b/src/routes/register/+page.svelte @@ -0,0 +1,137 @@ + + + + Request Access — {data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'} + + +
+
+
+

{data?.orgConfig?.orgInfo?.name ?? 'Pet Rescue'}

+

Staff & Foster Portal

+
+ + {#if form?.success} +
+
+
+ + + + Request Submitted +
+

Your registration request has been submitted. You'll receive an email when your account is approved.

+
+ +
+ {:else} +
+
+

Request Access

+

Submit a request to create an account. Your request will be reviewed by our team.

+
+ + {#if form?.error} +
+ {form.error} +
+ {/if} + +
+ + +
+ +
+ + +
+ +
+ + +

Minimum 8 characters

+
+ +
+ + +
+ +
+ + +
+ + + +

+ Already have an account? + Sign in +

+
+ {/if} +
+