feat(ui): settings page with user profile and admin user management

- Settings page: edit display name/email, change password
- Admin user management page: create/delete users, toggle roles
- Profile update API endpoint (PUT /api/auth/profile)
- Settings link added to sidebar navigation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-09 01:10:37 +01:00
committed by Fred Cabassut
co-authored by Claude Opus 4.6
parent 33864d9355
commit 263fbbdf15
5 changed files with 392 additions and 0 deletions
+17
View File
@@ -73,6 +73,23 @@
</li>
</ul>
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
<ul class="space-y-1">
<li>
<a
href="/settings"
class="flex w-full items-center gap-3 rounded-full px-6 py-3 text-left text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-800"
>
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Settings
</a>
</li>
</ul>
</div>
{#if $allTags.length > 0}
<div class="mt-6 border-t border-[var(--border-subtle)] pt-4">
<h3 class="px-6 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Tags</h3>
+16
View File
@@ -0,0 +1,16 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { getUserId } from '$lib/server/api-utils.js';
import { updateUserProfile } from '$lib/server/auth.js';
export const PUT: RequestHandler = async ({ request, ...event }) => {
const userId = getUserId(event);
const { displayName, email } = await request.json();
if (email !== undefined && !email) {
throw error(400, 'Email cannot be empty');
}
updateUserProfile(userId, { displayName, email });
return json({ success: true });
};
+170
View File
@@ -0,0 +1,170 @@
<script lang="ts">
import { goto } from '$app/navigation';
let { data } = $props();
let displayName = $state(data.user?.displayName || '');
let email = $state(data.user?.email || '');
let profileMsg = $state('');
let profileError = $state(false);
let currentPassword = $state('');
let newPassword = $state('');
let confirmNewPassword = $state('');
let passwordMsg = $state('');
let passwordError = $state(false);
async function saveProfile() {
profileMsg = '';
try {
const res = await fetch('/api/auth/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName, email })
});
if (res.ok) {
profileMsg = 'Profile updated';
profileError = false;
} else {
const d = await res.json();
profileMsg = d.message || 'Failed to update';
profileError = true;
}
} catch {
profileMsg = 'Connection error';
profileError = true;
}
}
async function changePassword() {
passwordMsg = '';
if (newPassword.length < 8) {
passwordMsg = 'Password must be at least 8 characters';
passwordError = true;
return;
}
if (newPassword !== confirmNewPassword) {
passwordMsg = 'Passwords do not match';
passwordError = true;
return;
}
try {
const res = await fetch('/api/auth/password', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword, newPassword })
});
if (res.ok) {
passwordMsg = 'Password changed';
passwordError = false;
currentPassword = '';
newPassword = '';
confirmNewPassword = '';
} else {
const d = await res.json();
passwordMsg = d.message || 'Failed to change password';
passwordError = true;
}
} catch {
passwordMsg = 'Connection error';
passwordError = true;
}
}
</script>
<svelte:head>
<title>Settings - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<h1 class="mb-8 text-2xl font-bold text-gray-800 dark:text-gray-100">Settings</h1>
<!-- Profile -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Profile</h2>
{#if profileMsg}
<div class="mb-4 rounded p-3 text-sm {profileError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{profileMsg}
</div>
{/if}
<div class="space-y-4">
<div>
<label for="display-name" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
<input
id="display-name"
type="text"
bind:value={displayName}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<div>
<label for="email" class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input
id="email"
type="email"
bind:value={email}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
</div>
<button
onclick={saveProfile}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Save profile
</button>
</div>
</section>
<!-- Change Password -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Change Password</h2>
{#if passwordMsg}
<div class="mb-4 rounded p-3 text-sm {passwordError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{passwordMsg}
</div>
{/if}
<div class="space-y-4">
<input
type="password"
bind:value={currentPassword}
placeholder="Current password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="New password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={confirmNewPassword}
placeholder="Confirm new password"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<button
onclick={changePassword}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Change password
</button>
</div>
</section>
<!-- Admin: User Management Link -->
{#if data.user?.role === 'admin'}
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Administration</h2>
<a
href="/settings/users"
class="text-sm text-amber-600 hover:text-amber-700 hover:underline dark:text-amber-400"
>
Manage users →
</a>
</section>
{/if}
</div>
+10
View File
@@ -0,0 +1,10 @@
import { redirect } from '@sveltejs/kit';
import type { PageServerLoad } from './$types.js';
import { listUsers } from '$lib/server/auth.js';
export const load: PageServerLoad = ({ locals }) => {
if (!locals.user || locals.user.role !== 'admin') {
throw redirect(302, '/settings');
}
return { users: listUsers() };
};
+179
View File
@@ -0,0 +1,179 @@
<script lang="ts">
import type { User } from '$lib/types/index.js';
let { data } = $props();
let users = $state<User[]>(data.users);
// Create user form
let newEmail = $state('');
let newDisplayName = $state('');
let newPassword = $state('');
let newRole = $state<'admin' | 'user'>('user');
let createMsg = $state('');
let createError = $state(false);
async function createUser() {
createMsg = '';
if (!newEmail || !newPassword) {
createMsg = 'Email and password are required';
createError = true;
return;
}
if (newPassword.length < 8) {
createMsg = 'Password must be at least 8 characters';
createError = true;
return;
}
try {
const res = await fetch('/api/admin/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: newEmail,
displayName: newDisplayName || newEmail.split('@')[0],
password: newPassword,
role: newRole
})
});
if (res.ok) {
const user = await res.json();
users = [...users, user];
newEmail = '';
newDisplayName = '';
newPassword = '';
newRole = 'user';
createMsg = 'User created';
createError = false;
} else {
const d = await res.json();
createMsg = d.message || 'Failed to create user';
createError = true;
}
} catch {
createMsg = 'Connection error';
createError = true;
}
}
async function deleteUser(userId: number) {
if (!confirm('Are you sure you want to delete this user and all their data?')) return;
try {
const res = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' });
if (res.ok) {
users = users.filter((u) => u.id !== userId);
}
} catch {
// ignore
}
}
async function toggleRole(user: User) {
const newRoleValue = user.role === 'admin' ? 'user' : 'admin';
try {
const res = await fetch(`/api/admin/users/${user.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRoleValue })
});
if (res.ok) {
users = users.map((u) => (u.id === user.id ? { ...u, role: newRoleValue } : u));
}
} catch {
// ignore
}
}
</script>
<svelte:head>
<title>User Management - KeepNotes</title>
</svelte:head>
<div class="mx-auto max-w-2xl py-8">
<div class="mb-8 flex items-center gap-4">
<a href="/settings" class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">← Settings</a>
<h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100">User Management</h1>
</div>
<!-- Create User -->
<section class="mb-8 rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Create User</h2>
{#if createMsg}
<div class="mb-4 rounded p-3 text-sm {createError ? 'bg-red-50 text-red-600 dark:bg-red-900/30 dark:text-red-400' : 'bg-green-50 text-green-600 dark:bg-green-900/30 dark:text-green-400'}">
{createMsg}
</div>
{/if}
<div class="space-y-3">
<input
type="email"
bind:value={newEmail}
placeholder="Email"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="text"
bind:value={newDisplayName}
placeholder="Display name (optional)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<input
type="password"
bind:value={newPassword}
placeholder="Password (min 8 characters)"
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
/>
<select
bind:value={newRole}
class="w-full rounded-lg border border-gray-300 px-4 py-2 text-sm outline-none focus:border-amber-500 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
>
<option value="user">User</option>
<option value="admin">Admin</option>
</select>
<button
onclick={createUser}
class="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600"
>
Create user
</button>
</div>
</section>
<!-- User List -->
<section class="rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-800">
<h2 class="mb-4 text-lg font-semibold text-gray-800 dark:text-gray-100">Users ({users.length})</h2>
<div class="space-y-3">
{#each users as user (user.id)}
<div class="flex items-center justify-between rounded-lg border border-gray-100 p-4 dark:border-gray-700">
<div>
<p class="font-medium text-gray-800 dark:text-gray-100">
{user.displayName || user.email}
{#if user.role === 'admin'}
<span class="ml-2 rounded bg-amber-100 px-2 py-0.5 text-xs text-amber-800 dark:bg-amber-900/30 dark:text-amber-200">admin</span>
{/if}
</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={() => toggleRole(user)}
class="rounded px-3 py-1 text-xs text-gray-600 hover:bg-gray-100 dark:text-gray-400 dark:hover:bg-gray-700"
title={user.role === 'admin' ? 'Demote to user' : 'Promote to admin'}
>
{user.role === 'admin' ? 'Make user' : 'Make admin'}
</button>
{#if user.id !== data.user?.id}
<button
onclick={() => deleteUser(user.id)}
class="rounded px-3 py-1 text-xs text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
Delete
</button>
{/if}
</div>
</div>
{/each}
</div>
</section>
</div>