Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -7,28 +7,46 @@
|
||||
let settings = $state<FleetSettings | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
onMount(() => { void loadSettings(); });
|
||||
onMount(() => {
|
||||
void loadSettings();
|
||||
});
|
||||
|
||||
async function loadSettings() {
|
||||
loading = true; error = '';
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const response = await fetch('/api/settings');
|
||||
if (!response.ok) throw new Error('Could not load fleet settings.');
|
||||
settings = validateFleetSettings(await response.json());
|
||||
} catch (cause) { error = cause instanceof Error ? cause.message : 'Could not load fleet settings.'; }
|
||||
finally { loading = false; }
|
||||
} catch (cause) {
|
||||
error = cause instanceof Error ? cause.message : 'Could not load fleet settings.';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Settings · Electric Cherry</title></svelte:head>
|
||||
<section class="page">
|
||||
<SectionHeader title="Settings" subtitle="Shared fleet limits and history retention. Changes apply without restarting." />
|
||||
<SectionHeader
|
||||
title="Settings"
|
||||
subtitle="Shared fleet limits and history retention. Changes apply without restarting."
|
||||
/>
|
||||
{#if loading}<p role="status">Loading fleet settings…</p>
|
||||
{:else if settings}<SettingsEditor initial={settings} />
|
||||
{:else}<div><p class="ui-error" role="alert">{error}</p><Button onclick={loadSettings}>Retry loading</Button></div>{/if}
|
||||
{:else}<div>
|
||||
<p class="ui-error" role="alert">{error}</p>
|
||||
<Button onclick={loadSettings}>Retry loading</Button>
|
||||
</div>{/if}
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.page { display: grid; gap: var(--space-4); }
|
||||
p { font-size: var(--text-sm); color: var(--text-secondary); }
|
||||
.page {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
p {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,148 +8,732 @@
|
||||
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 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('');
|
||||
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 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: 'operator', label: 'Operator', description: 'Operate servers, campaigns, scripts, and configs.' },
|
||||
{ value: 'admin', label: 'Administrator', description: 'Manage users, access, settings, hosts, and the fleet.' }
|
||||
{
|
||||
value: 'viewer',
|
||||
label: 'Viewer',
|
||||
description: 'Read fleet status and operation summaries.'
|
||||
},
|
||||
{
|
||||
value: 'operator',
|
||||
label: 'Operator',
|
||||
description: 'Operate servers, campaigns, scripts, and configs.'
|
||||
},
|
||||
{
|
||||
value: 'admin',
|
||||
label: 'Administrator',
|
||||
description: 'Manage users, access, settings, hosts, and the fleet.'
|
||||
}
|
||||
];
|
||||
const roleDescription = $derived(roles.find(item => item.value === role)?.description);
|
||||
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/users'); const body = await response.json();
|
||||
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; }
|
||||
} catch (cause) {
|
||||
error = (cause as Error).message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
async function loadAudit() {
|
||||
auditLoading = true; auditError = '';
|
||||
auditLoading = true;
|
||||
auditError = '';
|
||||
try {
|
||||
const response = await fetch('/api/users/audit'); const body = await response.json();
|
||||
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; }
|
||||
} 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 (administrator) void Promise.all([load(), loadAudit()]); else { loading = false; 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 (administrator) void Promise.all([load(), loadAudit()]);
|
||||
else {
|
||||
loading = false;
|
||||
auditLoading = false;
|
||||
}
|
||||
});
|
||||
function openCreate() {
|
||||
username = ''; displayName = ''; password = ''; role = 'viewer'; enabled = true; showPassword = false;
|
||||
formError = ''; editing = null; createOpen = true;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
role = 'viewer';
|
||||
enabled = true;
|
||||
showPassword = false;
|
||||
formError = '';
|
||||
editing = null;
|
||||
createOpen = true;
|
||||
}
|
||||
function openEdit(user: AuthUser) {
|
||||
editing = user; username = user.username; displayName = user.displayName; password = ''; role = user.role;
|
||||
enabled = !user.disabled; showPassword = false; formError = ''; editOpen = true;
|
||||
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 (busy) return; busy = true; formError = ''; error = ''; notice = '';
|
||||
event.preventDefault();
|
||||
if (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 }) });
|
||||
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.`;
|
||||
const response = await fetch('/api/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, displayName, password, role })
|
||||
});
|
||||
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 { busy = false; }
|
||||
} catch (cause) {
|
||||
formError = (cause as Error).message;
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault(); if (busy || !editing) return; busy = true; formError = ''; error = ''; notice = '';
|
||||
const ownAccessChanged = editing.id === $page.data.user?.id && (role !== editing.role || enabled === editing.disabled || !!password);
|
||||
event.preventDefault();
|
||||
if (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, ...(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 { busy = false; }
|
||||
const response = await fetch(`/api/users/${encodeURIComponent(editing.id)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
displayName,
|
||||
role,
|
||||
disabled: !enabled,
|
||||
...(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 {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
async function revoke(user: AuthUser) {
|
||||
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 = '';
|
||||
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; }
|
||||
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; }
|
||||
} 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="Create accounts and manage who can operate your fleet.">
|
||||
{#snippet actions()}{#if administrator}<Button variant="primary" disabled={busy} onclick={openCreate}>Add user</Button>{/if}{/snippet}
|
||||
{#snippet actions()}{#if administrator}<Button
|
||||
variant="primary"
|
||||
disabled={busy}
|
||||
onclick={openCreate}>Add user</Button
|
||||
>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if !administrator}<p class="error" role="alert">Only administrators can manage users.</p><a href="/account">Go to your account</a>
|
||||
{#if !administrator}<p class="error" role="alert">Only administrators can manage 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 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 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">{user.role === 'admin' ? 'Administrator' : 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>
|
||||
<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>
|
||||
</article>{/each}</div>
|
||||
<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"
|
||||
>{user.role === 'admin' ? 'Administrator' : 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>
|
||||
<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>
|
||||
</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>
|
||||
<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>
|
||||
{#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}
|
||||
{: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>
|
||||
|
||||
<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}
|
||||
<label for="new-username">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" /></label>
|
||||
<p class="hint" id="new-username-help">3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a letter or number.</p>
|
||||
<label for="new-name">Display name<input id="new-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
|
||||
<label for="new-role">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></label><p class="hint" id="new-role-help">{roleDescription}</p>
|
||||
<label for="temporary-password">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" /></label>
|
||||
<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>
|
||||
<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
|
||||
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}
|
||||
<label for="new-username"
|
||||
>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"
|
||||
/></label
|
||||
>
|
||||
<p class="hint" id="new-username-help">
|
||||
3–64 characters: letters, numbers, periods, underscores, and hyphens. Start with a
|
||||
letter or number.
|
||||
</p>
|
||||
<label for="new-name"
|
||||
>Display name<input
|
||||
id="new-name"
|
||||
name="displayName"
|
||||
required
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
bind:value={displayName}
|
||||
/></label
|
||||
>
|
||||
<label for="new-role"
|
||||
>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
|
||||
></label
|
||||
>
|
||||
<p class="hint" id="new-role-help">{roleDescription}</p>
|
||||
<label for="temporary-password"
|
||||
>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"
|
||||
/></label
|
||||
>
|
||||
<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
|
||||
>
|
||||
<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}
|
||||
<label for="edit-name">Display name<input id="edit-name" name="displayName" required maxlength="100" autocomplete="off" bind:value={displayName} /></label>
|
||||
<label for="edit-role">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></label><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}<label for="reset-password">New temporary password <span class="optional">(optional)</span><input id="reset-password" name="password" type={showPassword ? 'text' : 'password'} minlength="8" maxlength="128" autocomplete="new-password" bind:value={password} aria-describedby="reset-help" /></label><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}
|
||||
<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
|
||||
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}
|
||||
<label for="edit-name"
|
||||
>Display name<input
|
||||
id="edit-name"
|
||||
name="displayName"
|
||||
required
|
||||
maxlength="100"
|
||||
autocomplete="off"
|
||||
bind:value={displayName}
|
||||
/></label
|
||||
>
|
||||
<label for="edit-role"
|
||||
>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
|
||||
></label
|
||||
>
|
||||
<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}<label for="reset-password"
|
||||
>New temporary password <span class="optional">(optional)</span><input
|
||||
id="reset-password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
minlength="8"
|
||||
maxlength="128"
|
||||
autocomplete="new-password"
|
||||
bind:value={password}
|
||||
aria-describedby="reset-help"
|
||||
/></label
|
||||
>
|
||||
<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}
|
||||
<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>
|
||||
|
||||
<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(3,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,.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']),select { 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; } }
|
||||
.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(3, 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']),
|
||||
select {
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user