Private
Public Access
1
0

Add account settings page with profile, password, and session management

- /account page: edit name/email, change password, view/revoke sessions
- PATCH /api/account: update profile or change password (verifies current)
- DELETE /api/account: revoke all other sessions
- Password change auto-revokes other sessions for security
- Username in nav now links to /account (desktop + mobile)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Catherine Renelle
2026-02-26 08:22:34 -05:00
parent f91b8a4bd4
commit 1419300baa
4 changed files with 308 additions and 2 deletions
+2 -2
View File
@@ -70,7 +70,7 @@
Admin Admin
</a> </a>
{/if} {/if}
<span class="text-white/80 text-sm">{data.user.name}</span> <a href="/account" class="text-white/80 text-sm hover:text-white transition">{data.user.name}</a>
<NotificationBell /> <NotificationBell />
<button <button
onclick={async () => { onclick={async () => {
@@ -142,7 +142,7 @@
<SearchBar /> <SearchBar />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="text-white/80 text-sm py-1">{data.user.name}</span> <a href="/account" class="text-white/80 text-sm py-1 hover:text-white transition" onclick={() => (mobileMenuOpen = false)}>{data.user.name}</a>
{#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'} {#if data.user.tenantRole === 'ADMIN' || data.user.globalRole === 'SITE_ADMIN'}
<a <a
href="/admin" href="/admin"
+16
View File
@@ -0,0 +1,16 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { withBypassRLS } from '$lib/server/rls.js';
export const load: PageServerLoad = async ({ locals }) => {
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
};
};
+178
View File
@@ -0,0 +1,178 @@
<script lang="ts">
import { api } from '$lib/utils/api.js';
import { toast } from '$lib/stores/toasts.svelte.js';
let { data } = $props();
// Profile
let name = $state(data.user.name);
let email = $state(data.user.email);
let savingProfile = $state(false);
// Password
let currentPassword = $state('');
let newPassword = $state('');
let confirmPassword = $state('');
let savingPassword = $state(false);
// Sessions
let sessionCount = $state(data.sessionCount);
let revokingSessions = $state(false);
async function saveProfile() {
savingProfile = true;
const result = await api('/api/account', {
method: 'PATCH',
body: JSON.stringify({ name: name.trim(), email: email.trim() })
});
savingProfile = false;
if (result.ok) {
toast.success('Profile updated');
}
}
async function changePassword() {
if (newPassword !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
savingPassword = true;
const result = await api('/api/account', {
method: 'PATCH',
body: JSON.stringify({ currentPassword, newPassword })
});
savingPassword = false;
if (result.ok) {
currentPassword = '';
newPassword = '';
confirmPassword = '';
sessionCount = 1; // only current session remains
toast.success('Password changed. Other sessions have been logged out.');
}
}
async function revokeOtherSessions() {
revokingSessions = true;
const result = await api('/api/account', { method: 'DELETE' });
revokingSessions = false;
if (result.ok) {
sessionCount = 1;
toast.success(`Logged out of ${result.data.sessionsRevoked} other session(s)`);
}
}
</script>
<svelte:head>
<title>Account Settings</title>
</svelte:head>
<div class="mx-auto max-w-2xl px-4 py-8">
<h1 class="text-xl font-bold text-gray-900 dark:text-gray-100 mb-6">Account Settings</h1>
<!-- Profile -->
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Profile</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<form onsubmit={(e) => { e.preventDefault(); saveProfile(); }} class="space-y-3">
<div>
<label for="profile-name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Name</label>
<input
id="profile-name"
type="text"
bind:value={name}
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="profile-email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input
id="profile-email"
type="email"
bind:value={email}
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={savingProfile || !name.trim() || !email.trim()}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 transition"
>
{savingProfile ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
</section>
<!-- Password -->
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Change Password</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<form onsubmit={(e) => { e.preventDefault(); changePassword(); }} class="space-y-3">
<div>
<label for="current-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Current Password</label>
<input
id="current-password"
type="password"
bind:value={currentPassword}
autocomplete="current-password"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="new-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<input
id="new-password"
type="password"
bind:value={newPassword}
autocomplete="new-password"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div>
<label for="confirm-password" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
<input
id="confirm-password"
type="password"
bind:value={confirmPassword}
autocomplete="new-password"
class="w-full rounded-lg border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 px-3 py-2 text-sm focus:border-[var(--color-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-primary)]"
/>
</div>
<div class="flex justify-end">
<button
type="submit"
disabled={savingPassword || !currentPassword || !newPassword || !confirmPassword}
class="rounded-lg bg-[var(--color-primary)] px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-50 transition"
>
{savingPassword ? 'Changing...' : 'Change Password'}
</button>
</div>
</form>
</div>
</section>
<!-- Sessions -->
<section>
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3">Sessions</h2>
<div class="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
You have <strong class="text-gray-900 dark:text-gray-100">{sessionCount}</strong> active session{sessionCount === 1 ? '' : 's'} (including this one).
</p>
{#if sessionCount > 1}
<button
onclick={revokeOtherSessions}
disabled={revokingSessions}
class="rounded-lg border border-red-300 dark:border-red-700 px-4 py-2 text-sm font-medium text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 transition"
>
{revokingSessions ? 'Logging out...' : 'Log out all other sessions'}
</button>
{/if}
</div>
</section>
</div>
+112
View File
@@ -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 });
};