This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||
import SettingsEditor from '$ui/components/SettingsEditor.svelte';
|
||||
import Button from '$ui/components/primitives/Button.svelte';
|
||||
@@ -33,7 +34,10 @@
|
||||
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 if settings}<SettingsEditor
|
||||
initial={settings}
|
||||
readOnly={$page.data.user?.role !== 'admin'}
|
||||
/>
|
||||
{:else}<div>
|
||||
<p class="ui-error" role="alert">{error}</p>
|
||||
<Button onclick={loadSettings}>Retry loading</Button>
|
||||
|
||||
@@ -44,6 +44,11 @@
|
||||
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',
|
||||
@@ -57,6 +62,7 @@
|
||||
];
|
||||
const roleDescription = $derived(roles.find((item) => item.value === role)?.description);
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
const canViewUsers = $derived(administrator || $page.data.user?.role === 'super_viewer');
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
@@ -104,13 +110,14 @@
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
if (administrator) void Promise.all([load(), loadAudit()]);
|
||||
if (canViewUsers) void Promise.all([load(), loadAudit()]);
|
||||
else {
|
||||
loading = false;
|
||||
auditLoading = false;
|
||||
}
|
||||
});
|
||||
function openCreate() {
|
||||
if (!administrator) return;
|
||||
username = '';
|
||||
displayName = '';
|
||||
password = '';
|
||||
@@ -122,6 +129,7 @@
|
||||
createOpen = true;
|
||||
}
|
||||
function openEdit(user: AuthUser) {
|
||||
if (!administrator) return;
|
||||
editing = user;
|
||||
username = user.username;
|
||||
displayName = user.displayName;
|
||||
@@ -134,7 +142,7 @@
|
||||
}
|
||||
async function create(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
if (!administrator || busy) return;
|
||||
busy = true;
|
||||
formError = '';
|
||||
error = '';
|
||||
@@ -159,7 +167,7 @@
|
||||
}
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || !editing) return;
|
||||
if (!administrator || busy || !editing) return;
|
||||
busy = true;
|
||||
formError = '';
|
||||
error = '';
|
||||
@@ -196,6 +204,7 @@
|
||||
}
|
||||
}
|
||||
async function revoke(user: AuthUser) {
|
||||
if (!administrator || busy) return;
|
||||
const own = user.id === $page.data.user?.id;
|
||||
if (
|
||||
!(await confirmAction({
|
||||
@@ -234,14 +243,19 @@
|
||||
|
||||
<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.">
|
||||
<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 !administrator}<p class="error" role="alert">Only administrators can manage users.</p>
|
||||
{#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
|
||||
@@ -274,7 +288,8 @@
|
||||
</div>
|
||||
<div class="access">
|
||||
<span class="role"
|
||||
>{user.role === 'admin' ? 'Administrator' : user.role}</span
|
||||
>{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
|
||||
@@ -292,16 +307,16 @@
|
||||
/>
|
||||
</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>
|
||||
{#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}
|
||||
@@ -355,163 +370,169 @@
|
||||
{/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}
|
||||
<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
|
||||
>
|
||||
<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}
|
||||
{#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="reset-password"
|
||||
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="reset-help"
|
||||
aria-describedby="temporary-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 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
|
||||
>{/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>
|
||||
<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}
|
||||
<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 {
|
||||
@@ -527,7 +548,7 @@
|
||||
}
|
||||
.roles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user