294 lines
8.0 KiB
Svelte
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>
|