Files
LaDOSE/LaDOSE.Src/LaDOSE.WebApp/src/routes/users/+page.svelte
T
2026-08-06 09:58:06 +02:00

294 lines
8.0 KiB
Svelte

<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>