Files
electric-cherry/src/routes/settings/users/+page.svelte
T

799 lines
28 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { invalidateAll } from '$app/navigation';
import type { AuthUser, UserRole } from '$lib/auth.js';
import { confirmAction } from '$ui/confirm.js';
import Button from '$ui/components/primitives/Button.svelte';
import Dialog from '$ui/components/primitives/Dialog.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let users = $state<AuthUser[]>([]);
let loading = $state(true);
let busy = $state(false);
let error = $state('');
let notice = $state('');
let formError = $state('');
let createOpen = $state(false);
let editOpen = $state(false);
let editing = $state<AuthUser | null>(null);
let username = $state('');
let displayName = $state('');
let password = $state('');
let currentPassword = $state('');
let role = $state<UserRole>('viewer');
let enabled = $state(true);
let showPassword = $state(false);
interface AuditEvent {
id: number;
actorId: string | null;
actor: string | null;
action: string;
targetId: string | null;
details: string;
createdAt: number;
}
let events = $state<AuditEvent[]>([]);
let auditLoading = $state(true);
let auditError = $state('');
const roles: { value: UserRole; label: string; description: string }[] = [
{
value: 'viewer',
label: 'Viewer',
description: 'Read fleet status and operation summaries.'
},
{
value: 'super_viewer',
label: 'Super Viewer',
description: 'Read all fleet and administrator pages without making changes.'
},
{
value: 'operator',
label: 'Operator',
description: 'Operate servers, campaigns, scripts, and configs.'
},
{
value: 'moderator',
label: 'Moderator',
description: 'Moderate players with server or game restrictions lasting up to 7 days.'
},
{
value: 'admin',
label: 'Administrator',
description: 'Manage users, access, settings, hosts, and the fleet.'
}
];
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
const administrator = $derived($page.data.user?.role === 'admin');
const rememberedSession = $derived($page.data.rememberedSession);
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
$effect(() => {
if (!createOpen && !editOpen) currentPassword = '';
});
async function load() {
loading = true;
try {
const response = await fetch('/api/users');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load users.');
users = body.users;
} catch (cause) {
error = (cause as Error).message;
} finally {
loading = false;
}
}
async function loadAudit() {
auditLoading = true;
auditError = '';
try {
const response = await fetch('/api/users/audit');
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not load account activity.');
events = body.events;
} catch (cause) {
auditError = (cause as Error).message;
} finally {
auditLoading = false;
}
}
const actionLabels: Record<string, string> = {
setup: 'Administrator created',
login: 'Signed in',
'login.failed': 'Sign-in failed',
logout: 'Signed out',
'sessions.revoked': 'Sessions ended',
'user.created': 'User created',
'user.updated': 'Access updated',
'password.changed': 'Password changed'
};
function auditDetails(value: string): string {
try {
const parsed = JSON.parse(value);
return Object.keys(parsed).length ? JSON.stringify(parsed, null, 2) : '';
} catch {
return value;
}
}
onMount(() => {
if (canViewUsers) void Promise.all([load(), loadAudit()]);
else {
loading = false;
auditLoading = false;
}
});
function openCreate() {
if (!administrator) return;
currentPassword = '';
username = '';
displayName = '';
password = '';
role = 'viewer';
enabled = true;
showPassword = false;
formError = '';
editing = null;
createOpen = true;
}
function openEdit(user: AuthUser) {
if (!administrator) return;
currentPassword = '';
editing = user;
username = user.username;
displayName = user.displayName;
password = '';
role = user.role;
enabled = !user.disabled;
showPassword = false;
formError = '';
editOpen = true;
}
async function create(event: SubmitEvent) {
event.preventDefault();
if (!administrator || busy) return;
busy = true;
formError = '';
error = '';
notice = '';
try {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, displayName, password, role, currentPassword })
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not create this user.');
password = '';
createOpen = false;
notice = `${body.user.displayName || body.user.username} was added. They must change the temporary password at sign-in.`;
await Promise.all([load(), loadAudit()]);
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
async function save(event: SubmitEvent) {
event.preventDefault();
if (!administrator || busy || !editing) return;
busy = true;
formError = '';
error = '';
notice = '';
const ownAccessChanged =
editing.id === $page.data.user?.id &&
(role !== editing.role || enabled === editing.disabled || !!password);
try {
const response = await fetch(`/api/users/${encodeURIComponent(editing.id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
displayName,
role,
disabled: !enabled,
currentPassword,
...(password ? { password } : {})
})
});
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not save user changes.');
password = '';
editOpen = false;
if (ownAccessChanged) {
window.location.assign('/login');
return;
}
notice = `Changes saved for ${body.user.displayName || body.user.username}.`;
await Promise.all([load(), loadAudit()]);
await invalidateAll();
} catch (cause) {
formError = (cause as Error).message;
} finally {
currentPassword = '';
busy = false;
}
}
async function revoke(user: AuthUser) {
if (!administrator || busy) return;
const own = user.id === $page.data.user?.id;
if (
!(await confirmAction({
title: `Sign out ${user.displayName || user.username}?`,
message: own
? 'This will end all of your sessions, including this one. You can sign in again with your password.'
: 'This ends all active sessions for this user. Their account and password remain available for a new sign-in.',
confirmLabel: 'Sign out sessions',
danger: true
}))
)
return;
busy = true;
error = '';
notice = '';
try {
const response = await fetch(
`/api/users/${encodeURIComponent(user.id)}/revoke-sessions`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }
);
const body = await response.json();
if (!response.ok) throw new Error(body.error ?? 'Could not end these sessions.');
if (own) {
window.location.assign('/login');
return;
}
notice = `All sessions ended for ${user.displayName || user.username}.`;
await loadAudit();
} catch (cause) {
error = (cause as Error).message;
} finally {
busy = false;
}
}
</script>
<svelte:head><title>Users · Electric Cherry</title></svelte:head>
<section class="page">
<SectionHeader
title="Users"
subtitle={administrator
? 'Create accounts and manage who can operate your fleet.'
: 'View fleet accounts, access roles, and account activity.'}
>
{#snippet actions()}{#if administrator}<Button
variant="primary"
disabled={busy}
onclick={openCreate}>Add user</Button
>{/if}{/snippet}
</SectionHeader>
{#if !canViewUsers}<p class="error" role="alert">You do not have access to view users.</p>
<a href="/account">Go to your account</a>
{:else}
{#if error}<p class="error" role="alert">{error}</p>{/if}{#if notice}<p
class="notice"
role="status"
>
{notice}
</p>{/if}
<div class="roles" aria-label="Access roles">
{#each roles as item}<div>
<h2>{item.label}</h2>
<p>{item.description}</p>
</div>{/each}
</div>
{#if loading}<p class="muted" role="status">Loading users…</p>
{:else if !users.length}<section class="panel">
<p>No users could be loaded.</p>
<Button onclick={load}>Try again</Button>
</section>
{:else}
<div class="user-list" aria-label="User accounts">
{#each users as user (user.id)}<article class="user-row">
<div class="user-name">
<h2>
{user.displayName}
{#if user.id === $page.data.user?.id}<span class="you">You</span
>{/if}
</h2>
<p>@{user.username}</p>
</div>
<div class="access">
<span class="role"
>{roles.find((item) => item.value === user.role)?.label ??
user.role}</span
><span class:disabled={user.disabled}
>{user.disabled ? 'Disabled' : 'Enabled'}</span
>{#if user.mustChangePassword && !user.disabled}<small
>Password change required</small
>{/if}
</div>
<div class="last-login">
<span>Last sign-in</span>
<p>
<RelativeTime
value={user.lastLoginAt}
missing={user.id === $page.data.user?.id
? 'Signed in · time not recorded'
: 'Not recorded'}
/>
</p>
</div>
{#if administrator}<div class="actions">
<Button size="sm" disabled={busy} onclick={() => openEdit(user)}
>Edit access</Button
><Button
size="sm"
variant="ghost"
disabled={busy}
onclick={() => revoke(user)}>Sign out sessions</Button
>
</div>{/if}
</article>{/each}
</div>
{/if}
<section class="panel audit" aria-labelledby="audit-title">
<div class="audit-heading">
<div>
<h2 id="audit-title">Account activity</h2>
<p class="muted">The latest 200 sign-in and account events.</p>
</div>
<Button size="sm" variant="ghost" disabled={auditLoading} onclick={loadAudit}
>Refresh activity</Button
>
</div>
{#if auditError}<p class="error" role="alert">{auditError}</p>{/if}
{#if auditLoading && !events.length}<p class="muted" role="status">
Loading account activity…
</p>
{:else if !events.length}<p class="muted">No account events recorded.</p>
{:else}<div class="table-wrap">
<table>
<thead
><tr
><th>Time</th><th>Event</th><th>By</th><th>Account</th><th
>Details</th
></tr
></thead
><tbody>
{#each events as event (event.id)}{@const details = auditDetails(
event.details
)}<tr
><td class="event-time"
><RelativeTime value={event.createdAt} /></td
><td>{actionLabels[event.action] ?? event.action}</td><td
>{event.actor ? `@${event.actor}` : 'Unauthenticated'}</td
><td
>{event.targetId
? (users.find((user) => user.id === event.targetId)
?.username ?? event.targetId)
: '—'}</td
><td
>{#if details}<details>
<summary>View details</summary>
<pre>{details}</pre>
</details>{:else}—{/if}</td
></tr
>{/each}
</tbody>
</table>
</div>{/if}
</section>
{/if}
</section>
{#snippet accountPassword(id: string)}
{#if rememberedSession}
<Field forId={id} label="Your current password">
<Input
{id}
name="currentPassword"
type="password"
autocomplete="current-password"
required
maxlength={128}
bind:value={currentPassword}
aria-describedby={`${id}-help`}
/>
</Field>
<p class="hint" id={`${id}-help`}>
Confirm your password to make account changes on this remembered device.
</p>
{/if}
{/snippet}
{#if administrator}<Dialog
bind:open={createOpen}
title="Add user"
description="Share the temporary password privately. The user must choose a new password when they first sign in."
{busy}
>
<form onsubmit={create} aria-busy={busy}>
<fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<Field forId="new-username" label="Username">
<Input
id="new-username"
name="username"
required
minlength={3}
maxlength={64}
pattern={'[a-zA-Z0-9][a-zA-Z0-9_.\\-]{2,63}'}
autocomplete="off"
autocapitalize="none"
spellcheck="false"
bind:value={username}
aria-describedby="new-username-help"
/></Field
>
<p class="hint" id="new-username-help">
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with
a letter or number.
</p>
<Field forId="new-name" label="Display name">
<Input
id="new-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="new-role" label="Role">
<Select
id="new-role"
name="role"
bind:value={role}
aria-describedby="new-role-help"
>{#each roles as item}<option value={item.value}>{item.label}</option
>{/each}</Select
></Field
>
<p class="hint" id="new-role-help">{roleDescription}</p>
<Field forId="temporary-password" label="Temporary password">
<Input
id="temporary-password"
name="password"
type={showPassword ? 'text' : 'password'}
required
minlength={8}
maxlength={128}
autocomplete="new-password"
bind:value={password}
aria-describedby="temporary-help"
/></Field
>
<p class="hint" id="temporary-help">
8–128 characters. Save it before adding the user; it will not be displayed
again.
</p>
<label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary password</label
>
{@render accountPassword('create-current-password')}
<div class="form-actions">
<Button
disabled={busy}
variant="ghost"
onclick={() => {
createOpen = false;
password = '';
}}>Cancel</Button
><Button type="submit" variant="primary" loading={busy} disabled={busy}
>Add user</Button
>
</div>
</fieldset>
</form>
</Dialog>
<Dialog
bind:open={editOpen}
title={`Edit ${editing?.username ?? 'user'}`}
description="Update access or set a new temporary password."
{busy}
>
<form onsubmit={save} aria-busy={busy}>
<fieldset disabled={busy}>
{#if formError}<p class="error" role="alert">{formError}</p>{/if}
<Field forId="edit-name" label="Display name">
<Input
id="edit-name"
name="displayName"
required
maxlength={100}
autocomplete="off"
bind:value={displayName}
/></Field
>
<Field forId="edit-role" label="Role">
<Select
id="edit-role"
name="role"
bind:value={role}
aria-describedby="edit-role-help"
>{#each roles as item}<option value={item.value}>{item.label}</option
>{/each}</Select
></Field
>
<p class="hint" id="edit-role-help">{roleDescription}</p>
<label class="checkbox"
><input type="checkbox" bind:checked={enabled} />Account enabled</label
>
<p class="hint">
Disabled users cannot sign in. Running game servers continue operating.
</p>
{#if editing?.id === $page.data.user?.id}<p class="hint">
Use <a href="/account">My account</a> to change your own password. Changes to
your access can sign you out.
</p>
{:else}<Field forId="reset-password"
>{#snippet label()}New temporary password <span class="optional"
>(optional)</span
>{/snippet}
<Input
id="reset-password"
name="password"
type={showPassword ? 'text' : 'password'}
minlength={8}
maxlength={128}
autocomplete="new-password"
bind:value={password}
aria-describedby="reset-help"
/></Field
>
<p class="hint" id="reset-help">
Leave blank to keep the current password. A reset requires the user to
choose a new password at their next sign-in.
</p>
<label class="checkbox"
><input type="checkbox" bind:checked={showPassword} />Show temporary
password</label
>{/if}
{@render accountPassword('edit-current-password')}
<div class="form-actions">
<Button
disabled={busy}
variant="ghost"
onclick={() => {
editOpen = false;
password = '';
}}>Cancel</Button
><Button type="submit" variant="primary" loading={busy} disabled={busy}
>Save changes</Button
>
</div>
</fieldset>
</form>
</Dialog>{/if}
<style>
.page {
display: grid;
gap: 24px;
}
.panel,
.roles,
.user-list {
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface);
}
.roles {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 24px;
padding: 24px;
}
h2 {
margin: 0;
font-size: var(--text-md);
}
.roles p,
.muted {
color: var(--text-secondary);
font-size: var(--text-sm);
line-height: 1.6;
}
.roles p {
margin: 8px 0 0;
}
.panel {
padding: 24px;
}
.user-row {
display: grid;
grid-template-columns: minmax(180px, 1.3fr) minmax(120px, 0.8fr) minmax(140px, 1fr) auto;
gap: 24px;
align-items: center;
padding: 24px;
}
.user-row + .user-row {
border-top: 1px solid var(--border-subtle);
}
.user-name p {
color: var(--text-secondary);
font-size: var(--text-sm);
margin: 6px 0 0;
}
.you {
padding: 3px 7px;
margin-left: 6px;
background: var(--surface-raised);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--text-xs);
font-weight: normal;
}
.access {
display: grid;
gap: 5px;
font-size: var(--text-sm);
}
.access .role {
color: var(--text-primary);
text-transform: capitalize;
}
.access span:not(.role),
.access small {
color: var(--text-secondary);
font-size: var(--text-xs);
}
.access span.disabled {
color: var(--state-danger);
}
.last-login > span {
font-size: var(--text-xs);
color: var(--text-secondary);
}
.last-login p {
font-size: var(--text-sm);
margin: 5px 0 0;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: end;
}
.audit {
min-width: 0;
}
.audit-heading {
display: flex;
gap: 16px;
align-items: start;
justify-content: space-between;
flex-wrap: wrap;
}
.audit-heading p {
margin: 6px 0 20px;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: var(--text-sm);
}
th,
td {
padding: 12px;
border-bottom: 1px solid var(--border-subtle);
vertical-align: top;
}
th {
color: var(--text-secondary);
font-weight: 500;
}
td {
overflow-wrap: anywhere;
}
.event-time {
min-width: 165px;
}
details {
min-width: 120px;
}
summary {
cursor: pointer;
color: var(--accent-primary-hover);
}
pre {
white-space: pre-wrap;
font-size: var(--text-xs);
max-width: 320px;
}
fieldset {
display: grid;
gap: 16px;
border: 0;
padding: 0;
margin: 0;
min-width: 0;
}
label {
display: grid;
gap: 8px;
font-size: var(--text-sm);
}
input:not([type='checkbox']) {
width: 100%;
padding: 11px;
color: var(--text-primary);
background: var(--surface-sunken);
border: 1px solid var(--border-control);
border-radius: var(--radius-md);
font: inherit;
}
.checkbox {
display: flex;
align-items: center;
gap: 10px;
}
.checkbox input {
width: 18px;
height: 18px;
accent-color: var(--accent-primary);
}
.hint {
font-size: var(--text-xs);
color: var(--text-secondary);
line-height: 1.6;
margin: -8px 0 0;
}
.optional {
color: var(--text-secondary);
}
.form-actions {
display: flex;
gap: 12px;
justify-content: end;
margin-top: 8px;
}
.error {
color: var(--state-danger);
font-size: var(--text-sm);
line-height: 1.6;
margin: 0;
}
.notice {
color: var(--state-online);
font-size: var(--text-sm);
margin: 0;
}
@media (max-width: 1100px) {
.user-row {
grid-template-columns: minmax(140px, 1.3fr) minmax(100px, 1fr) minmax(140px, 1fr);
}
.actions {
grid-column: 1/-1;
justify-content: start;
}
}
@media (max-width: 640px) {
.roles {
grid-template-columns: 1fr;
gap: 20px;
}
.user-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 20px;
padding: 20px;
}
.user-name {
grid-column: 1/-1;
}
.roles,
.panel {
padding: 20px;
}
}
</style>