diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 412c85c..b321cf8 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -70,7 +70,7 @@ Admin {/if} - {data.user.name} + {data.user.name} { @@ -142,7 +142,7 @@ - {data.user.name} + (mobileMenuOpen = false)}>{data.user.name} {#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'} { + if (!locals.user) throw redirect(302, '/auth/login'); + + const sessionCount = await withBypassRLS(async (tx) => { + return tx.session.count({ where: { userId: locals.user!.id } }); + }); + + return { + user: locals.user, + sessionCount + }; +}; diff --git a/src/routes/account/+page.svelte b/src/routes/account/+page.svelte new file mode 100644 index 0000000..375abaa --- /dev/null +++ b/src/routes/account/+page.svelte @@ -0,0 +1,178 @@ + + + + Account Settings + + + + Account Settings + + + + Profile + + { e.preventDefault(); saveProfile(); }} class="space-y-3"> + + Name + + + + Email + + + + + {savingProfile ? 'Saving...' : 'Save Changes'} + + + + + + + + + Change Password + + { e.preventDefault(); changePassword(); }} class="space-y-3"> + + Current Password + + + + New Password + + + + Confirm New Password + + + + + {savingPassword ? 'Changing...' : 'Change Password'} + + + + + + + + + Sessions + + + You have {sessionCount} active session{sessionCount === 1 ? '' : 's'} (including this one). + + {#if sessionCount > 1} + + {revokingSessions ? 'Logging out...' : 'Log out all other sessions'} + + {/if} + + + diff --git a/src/routes/api/account/+server.ts b/src/routes/api/account/+server.ts new file mode 100644 index 0000000..e585929 --- /dev/null +++ b/src/routes/api/account/+server.ts @@ -0,0 +1,112 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types.js'; +import { requireAuth } from '$lib/server/guards.js'; +import { hashPassword, verifyPassword } from '$lib/server/auth.js'; +import { withBypassRLS } from '$lib/server/rls.js'; +import { z } from 'zod'; + +const profileSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + email: z.string().email('Invalid email address') +}); + +const passwordSchema = z.object({ + currentPassword: z.string().min(1, 'Current password is required'), + newPassword: z.string().min(8, 'New password must be at least 8 characters') +}); + +// Update profile +export const PATCH: RequestHandler = async ({ request, locals }) => { + requireAuth(locals.user); + + const body = await request.json(); + + // Password change + if (body.currentPassword !== undefined) { + const result = passwordSchema.safeParse(body); + if (!result.success) { + return json({ error: result.error.issues[0].message }, { status: 400 }); + } + + const updated = await withBypassRLS(async (tx) => { + const user = await tx.user.findUnique({ where: { id: locals.user!.id } }); + if (!user) throw error(404, 'User not found'); + + const valid = await verifyPassword(result.data.currentPassword, user.passwordHash); + if (!valid) { + return null; + } + + const newHash = await hashPassword(result.data.newPassword); + await tx.user.update({ + where: { id: locals.user!.id }, + data: { passwordHash: newHash } + }); + + // Invalidate all other sessions + await tx.session.deleteMany({ + where: { + userId: locals.user!.id, + id: { not: locals.session!.id } + } + }); + + return true; + }); + + if (!updated) { + return json({ error: 'Current password is incorrect' }, { status: 400 }); + } + + return json({ ok: true }); + } + + // Profile update + const result = profileSchema.safeParse(body); + if (!result.success) { + return json({ error: result.error.issues[0].message }, { status: 400 }); + } + + const user = await withBypassRLS(async (tx) => { + // Check if email is already taken by another user in the same tenant + if (result.data.email !== locals.user!.email) { + const existing = await tx.user.findFirst({ + where: { + email: result.data.email, + tenantId: (await tx.user.findUnique({ where: { id: locals.user!.id }, select: { tenantId: true } }))!.tenantId, + id: { not: locals.user!.id } + } + }); + if (existing) return null; + } + + return tx.user.update({ + where: { id: locals.user!.id }, + data: { name: result.data.name, email: result.data.email }, + select: { id: true, name: true, email: true } + }); + }); + + if (!user) { + return json({ error: 'Email is already in use' }, { status: 409 }); + } + + return json({ user }); +}; + +// Delete all other sessions +export const DELETE: RequestHandler = async ({ locals }) => { + requireAuth(locals.user); + + const count = await withBypassRLS(async (tx) => { + const result = await tx.session.deleteMany({ + where: { + userId: locals.user!.id, + id: { not: locals.session!.id } + } + }); + return result.count; + }); + + return json({ ok: true, sessionsRevoked: count }); +};
+ You have {sessionCount} active session{sessionCount === 1 ? '' : 's'} (including this one). +