mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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:
committed by
Fred Cabassut
co-authored by
Claude Opus 4.6
parent
93aeb85a7e
commit
04e93ec541
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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() };
|
||||
};
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user