This commit is contained in:
@@ -0,0 +1,293 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { addUser, deleteUser, listRoles, listUsers } from '$lib/api/admin-users';
|
||||
import { toErrorMessage } from '$lib/api/errors';
|
||||
import type { ApplicationUserDTO } from '$lib/api/schema-helpers';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { alertError, alertNotice, card, cardHeading, danger, field, ghost, label, primary } from '$lib/ui/classes';
|
||||
|
||||
let users = $state<ApplicationUserDTO[]>([]);
|
||||
let roles = $state<string[]>([]);
|
||||
|
||||
let username = $state('');
|
||||
let password = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
let selectedRoles = $state<string[]>([]);
|
||||
|
||||
let loading = $state(false);
|
||||
let creating = $state(false);
|
||||
let deletingId = $state<number | null>(null);
|
||||
let notice = $state<string | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const canCreate = $derived(username.trim() !== '' && password !== '' && !creating);
|
||||
|
||||
let started = false;
|
||||
$effect(() => {
|
||||
// The page is admin-only on the server too; this just avoids showing a shell that
|
||||
// can only produce 403s.
|
||||
if (!session.isLoggedIn) {
|
||||
goto('/login', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!session.isAdmin) {
|
||||
goto('/', { replaceState: true });
|
||||
return;
|
||||
}
|
||||
if (!started) {
|
||||
started = true;
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
function report(cause: unknown, fallback: string) {
|
||||
error = toErrorMessage(cause, fallback);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
// Roles are reference data; fetch them alongside the list on first load.
|
||||
[users, roles] = await Promise.all([listUsers(), listRoles()]);
|
||||
} catch (cause) {
|
||||
report(cause, 'Could not load the accounts.');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleRole(role: string) {
|
||||
selectedRoles = selectedRoles.includes(role)
|
||||
? selectedRoles.filter((selected) => selected !== role)
|
||||
: [...selectedRoles, role];
|
||||
}
|
||||
|
||||
async function create() {
|
||||
if (!canCreate) return;
|
||||
|
||||
creating = true;
|
||||
error = null;
|
||||
notice = null;
|
||||
const name = username.trim();
|
||||
try {
|
||||
await addUser({
|
||||
username: name,
|
||||
password,
|
||||
firstName: firstName.trim() || null,
|
||||
lastName: lastName.trim() || null,
|
||||
roles: selectedRoles
|
||||
});
|
||||
notice = `Created "${name}".`;
|
||||
username = '';
|
||||
password = '';
|
||||
firstName = '';
|
||||
lastName = '';
|
||||
selectedRoles = [];
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
// A 400 carries the reason: username taken, missing password, unknown role.
|
||||
report(cause, `Could not create "${name}".`);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(user: ApplicationUserDTO) {
|
||||
if (user.id === undefined || deletingId !== null) return;
|
||||
if (!confirm(`Delete "${user.username}"? This cannot be undone.`)) return;
|
||||
|
||||
deletingId = user.id;
|
||||
error = null;
|
||||
notice = null;
|
||||
try {
|
||||
await deleteUser(user.id);
|
||||
notice = `Deleted "${user.username}".`;
|
||||
await refresh();
|
||||
} catch (cause) {
|
||||
report(cause, `Could not delete "${user.username}".`);
|
||||
} finally {
|
||||
deletingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function fullName(user: ApplicationUserDTO): string {
|
||||
return [user.firstName, user.lastName].filter(Boolean).join(' ').trim();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Users · LaDOSE</title>
|
||||
</svelte:head>
|
||||
|
||||
<main id="main" class="mx-auto max-w-5xl px-4 py-10">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl font-semibold tracking-tight">Users</h1>
|
||||
<p class="mt-1 text-sm text-muted">
|
||||
Accounts that can sign in. Admins may also manage this list.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<p role="alert" class="{alertError} mb-4">
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
{#if notice}
|
||||
<p class="{alertNotice} mb-4">{notice}</p>
|
||||
{/if}
|
||||
|
||||
<section class={card}>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<h2 class={cardHeading}>
|
||||
Accounts
|
||||
<span class="ml-1 font-normal text-muted normal-case">({users.length})</span>
|
||||
</h2>
|
||||
<button class={ghost} onclick={refresh} disabled={loading}>
|
||||
{loading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full min-w-max text-sm">
|
||||
<thead class="text-left text-xs tracking-wide text-muted uppercase">
|
||||
<tr class="border-b border-line">
|
||||
<th class="py-2 pr-4 font-medium">Username</th>
|
||||
<th class="py-2 pr-4 font-medium">Name</th>
|
||||
<th class="py-2 pr-4 font-medium">Roles</th>
|
||||
<th class="py-2 pl-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr class="border-b border-line/60 last:border-0">
|
||||
<td class="py-2 pr-4 font-medium">
|
||||
{user.username}
|
||||
{#if user.id === session.user?.id}
|
||||
<span class="ml-1 text-xs font-normal text-subtle">(you)</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 pr-4 text-muted">{fullName(user) || '—'}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if user.roles?.length}
|
||||
{#each user.roles as role (role)}
|
||||
<span
|
||||
class="mr-1 rounded-full px-2 py-0.5 text-xs {role.toLowerCase() === 'admin'
|
||||
? 'bg-accent/20 text-accent'
|
||||
: 'bg-ink/5 text-muted'}"
|
||||
>
|
||||
{role}
|
||||
</span>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-subtle">none</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2 pl-2 text-right">
|
||||
{#if user.id === session.user?.id}
|
||||
<!-- The API refuses this, which is what keeps an admin in place. -->
|
||||
<span class="text-xs text-subtle">can't delete yourself</span>
|
||||
{:else}
|
||||
<button
|
||||
class={danger}
|
||||
onclick={() => remove(user)}
|
||||
disabled={deletingId !== null}
|
||||
>
|
||||
{deletingId === user.id ? 'Deleting…' : 'Delete'}
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="4" class="py-6 text-center text-subtle">
|
||||
{loading ? 'Loading…' : 'No account.'}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="{card} mt-6">
|
||||
<h2 class={cardHeading}>Add a user</h2>
|
||||
|
||||
<form
|
||||
class="mt-5 grid gap-4 sm:grid-cols-2"
|
||||
onsubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void create();
|
||||
}}
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-username">Username</label>
|
||||
<input
|
||||
id="new-username"
|
||||
bind:value={username}
|
||||
class={field}
|
||||
autocomplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-password">Password</label>
|
||||
<input
|
||||
id="new-password"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
class={field}
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-firstname">First name</label>
|
||||
<input id="new-firstname" bind:value={firstName} class={field} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label class={label} for="new-lastname">Last name</label>
|
||||
<input id="new-lastname" bind:value={lastName} class={field} autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<fieldset class="space-y-2 sm:col-span-2">
|
||||
<legend class={label}>Roles</legend>
|
||||
{#if roles.length}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{#each roles as role (role)}
|
||||
<label
|
||||
class="flex cursor-pointer items-center gap-2 rounded-lg border border-line px-3 py-1.5 text-sm transition hover:bg-ink/5"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedRoles.includes(role)}
|
||||
onchange={() => toggleRole(role)}
|
||||
class="size-4 accent-accent"
|
||||
/>
|
||||
{role}
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
<p class="text-xs text-subtle">
|
||||
No role still signs in and uses everything else — only this page needs Admin.
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-xs text-warning">
|
||||
No role exists in the database yet. Run <code>Sql/2026-08-05_roles.sql</code> to seed
|
||||
Admin and User.
|
||||
</p>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<div class="border-t border-line pt-4 sm:col-span-2">
|
||||
<button type="submit" class={primary} disabled={!canCreate}>
|
||||
{creating ? 'Creating…' : 'Create user'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
Reference in New Issue
Block a user