This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
import { resolveConfirmation } from '$ui/confirm.js';
|
||||
import '$ui/styles/components.css';
|
||||
import { enableViewTransitions } from '$ui/motion/viewTransition.js';
|
||||
import type { AuthUser } from '$lib/auth.js';
|
||||
import { canReadAdmin, type AuthUser } from '$lib/auth.js';
|
||||
// Side-effect import — puts @fontsource CSS on Vite's module graph so
|
||||
// the referenced woff/woff2 files get emitted into the build.
|
||||
import '$ui/styles/fonts.js';
|
||||
@@ -172,7 +172,6 @@
|
||||
armTimer = null;
|
||||
}
|
||||
const dest = shortcuts[e.key];
|
||||
if ((e.key === 'h' || e.key === 'k') && data.user?.role !== 'admin') return;
|
||||
if (dest) {
|
||||
e.preventDefault();
|
||||
goto(dest);
|
||||
@@ -299,8 +298,11 @@
|
||||
>Keyboard shortcuts <span aria-hidden="true">?</span
|
||||
></button
|
||||
>{/if}
|
||||
{#if data.user.role === 'admin' && sessionReady}<a
|
||||
href="/settings/users">Manage users</a
|
||||
{#if canReadAdmin(data.user.role) && sessionReady}<a
|
||||
href="/settings/users"
|
||||
>{data.user.role === 'admin'
|
||||
? 'Manage users'
|
||||
: 'View users'}</a
|
||||
>{/if}
|
||||
<button disabled={signingOut} onclick={signOut}
|
||||
>{signingOut ? 'Signing out…' : 'Sign out'}</button
|
||||
@@ -319,6 +321,13 @@
|
||||
>You can read fleet status and summaries. Operating servers or viewing raw
|
||||
configs, file changes, logs, and telemetry requires an operator account.</span
|
||||
>
|
||||
</aside>{:else if sessionReady && data.user?.role === 'super_viewer'}<aside
|
||||
class="viewer-note"
|
||||
>
|
||||
<strong>Super Viewer access</strong><span
|
||||
>You can view all administration pages, configs, logs, and diagnostics. Changes
|
||||
and server operations are unavailable.</span
|
||||
>
|
||||
</aside>{/if}
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { roleLabels, type AuthUser } from '$lib/auth.js';
|
||||
import { timeFormat, timeZone, timeZonePersistenceError } from '$ui/stores/time-zone.js';
|
||||
import { availableTimeZones, DEFAULT_TIME_ZONE } from '$lib/time-zone.js';
|
||||
let exampleDate = $state<Date | null>(null);
|
||||
@@ -21,7 +22,7 @@
|
||||
let signingOut = $state(false);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
const user = $derived($page.data.user);
|
||||
const user = $derived<AuthUser | null>($page.data.user ?? null);
|
||||
async function changePassword(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (busy || signingOut) return;
|
||||
@@ -88,7 +89,7 @@
|
||||
<section class="panel identity">
|
||||
<div>
|
||||
<h2>{user.displayName}</h2>
|
||||
<p>@{user.username} · <span class="role">{user.role}</span></p>
|
||||
<p>@{user.username} · <span class="role">{roleLabels[user.role]}</span></p>
|
||||
</div>
|
||||
<Button disabled={busy || signingOut} loading={signingOut} onclick={logout}
|
||||
>Sign out</Button
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
@@ -49,6 +50,7 @@
|
||||
}
|
||||
|
||||
let betas = $state<BetaListRow[]>([]);
|
||||
const editable = $derived(canOperate($page.data?.user?.role));
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let tab = $state('active');
|
||||
@@ -122,7 +124,7 @@
|
||||
}
|
||||
onMount(() => {
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
if (query.has('artifact') || query.has('pluginId')) {
|
||||
if (editable && (query.has('artifact') || query.has('pluginId'))) {
|
||||
void goto('/betas/new' + window.location.search, { replaceState: true });
|
||||
return;
|
||||
}
|
||||
@@ -141,7 +143,7 @@
|
||||
<svelte:head><title>Betas · Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Betas">
|
||||
{#snippet actions()}{#if loading || betas.length || error}<Button
|
||||
{#snippet actions()}{#if editable && (loading || betas.length || error)}<Button
|
||||
variant="primary"
|
||||
href="/betas/new">New beta</Button
|
||||
>{/if}{/snippet}
|
||||
@@ -230,7 +232,8 @@
|
||||
pageNumber = 1;
|
||||
saveContext();
|
||||
}}>Clear filters</Button
|
||||
>{:else}<Button variant="primary" href="/betas/new">New beta</Button
|
||||
>{:else if editable}<Button variant="primary" href="/betas/new"
|
||||
>New beta</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||
import type { BetaTestView } from '$lib/beta-plan.js';
|
||||
import type { BetaPromotionPreview } from '$lib/beta-promotion.js';
|
||||
@@ -92,7 +93,9 @@
|
||||
let updateOpen = $state(false),
|
||||
updateRevision = $state(1),
|
||||
updateFiles = $state<BetaFile[]>([]);
|
||||
const editable = $derived(canOperate($page.data?.user?.role));
|
||||
function openUpdate() {
|
||||
if (!editable) return;
|
||||
updateRevision = revision.revision;
|
||||
updateFiles = [...scripts];
|
||||
updateOpen = true;
|
||||
@@ -157,6 +160,7 @@
|
||||
);
|
||||
|
||||
function testAction(action: string) {
|
||||
if (!editable) return;
|
||||
moreActionsOpen = false;
|
||||
void testProgress?.action(action);
|
||||
}
|
||||
@@ -260,7 +264,7 @@
|
||||
}
|
||||
}
|
||||
async function promote() {
|
||||
if (!preview) return;
|
||||
if (!editable || !preview) return;
|
||||
acting = 'promote';
|
||||
reviewError = '';
|
||||
flash = '';
|
||||
@@ -288,6 +292,7 @@
|
||||
}
|
||||
}
|
||||
async function rollback() {
|
||||
if (!editable) return;
|
||||
const reject = beta?.state === 'ready-promote';
|
||||
const restarts =
|
||||
test?.plan.rollbackRestart === 'manual'
|
||||
@@ -318,6 +323,7 @@
|
||||
}
|
||||
}
|
||||
async function retry(server: string) {
|
||||
if (!editable) return;
|
||||
acting = `retry:${server}`;
|
||||
flash = '';
|
||||
try {
|
||||
@@ -392,7 +398,7 @@
|
||||
$page.url
|
||||
)}>View rollback campaign</Button
|
||||
>
|
||||
{:else if closed}<Button
|
||||
{:else if closed && editable}<Button
|
||||
variant="primary"
|
||||
href={contextHref(`/betas/new?reuse=${id}`, $page.url)}
|
||||
>Reuse files and test plan</Button
|
||||
@@ -410,12 +416,12 @@
|
||||
? 'failures'
|
||||
: 'issues'}</Button
|
||||
>
|
||||
{:else if presentation.key === 'paused' && !testExpired}<Button
|
||||
{:else if editable && presentation.key === 'paused' && !testExpired}<Button
|
||||
variant="primary"
|
||||
onclick={() => testAction('resume')}
|
||||
disabled={busy}>Resume testing</Button
|
||||
>{/if}
|
||||
{#if !closed || promotion || rollbackCampaign}<details
|
||||
{#if editable && (!closed || promotion || rollbackCampaign)}<details
|
||||
class="more-actions"
|
||||
bind:open={moreActionsOpen}
|
||||
>
|
||||
@@ -583,7 +589,7 @@
|
||||
$page.url
|
||||
)}>View provisioning</a
|
||||
>{/if}
|
||||
{#if server.stage === 'failed' && !server.server_deleted_at && !closed}<Button
|
||||
{#if editable && server.stage === 'failed' && !server.server_deleted_at && !closed}<Button
|
||||
size="sm"
|
||||
onclick={() => retry(server.server)}
|
||||
disabled={busy}
|
||||
@@ -694,15 +700,15 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<BetaRevisionDialog
|
||||
bind:open={updateOpen}
|
||||
betaId={id}
|
||||
revision={updateRevision}
|
||||
files={updateFiles}
|
||||
servers={servers.map((server) => server.server)}
|
||||
timeoutHours={test?.plan.timeoutHours}
|
||||
onUpdate={load}
|
||||
/>
|
||||
{#if editable}<BetaRevisionDialog
|
||||
bind:open={updateOpen}
|
||||
betaId={id}
|
||||
revision={updateRevision}
|
||||
files={updateFiles}
|
||||
servers={servers.map((server) => server.server)}
|
||||
timeoutHours={test?.plan.timeoutHours}
|
||||
onUpdate={load}
|
||||
/>{/if}
|
||||
|
||||
<Dialog
|
||||
bind:open={reviewOpen}
|
||||
@@ -763,13 +769,13 @@
|
||||
>Cancel</Button
|
||||
>{#if preview}<Button onclick={reviewPromotion} disabled={acting !== null}
|
||||
>Refresh review</Button
|
||||
><Button
|
||||
variant="primary"
|
||||
onclick={promote}
|
||||
loading={acting === 'promote'}
|
||||
disabled={acting !== null || !!preview.blockers.length}
|
||||
>Promote to {preview.servers.length} servers</Button
|
||||
>{/if}{/snippet}
|
||||
>{#if editable}<Button
|
||||
variant="primary"
|
||||
onclick={promote}
|
||||
loading={acting === 'promote'}
|
||||
disabled={acting !== null || !!preview.blockers.length}
|
||||
>Promote to {preview.servers.length} servers</Button
|
||||
>{/if}{/if}{/snippet}
|
||||
</Dialog>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import { countLabel } from '$ui/plural.js';
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { contextHref, safeReturnHref } from '$lib/navigation-context.js';
|
||||
import Breadcrumbs from '$ui/components/primitives/Breadcrumbs.svelte';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
@@ -91,6 +92,7 @@
|
||||
availability = $state(''),
|
||||
now = $state(Date.now());
|
||||
const steps = ['Files', 'Servers', 'Test plan', 'Review'];
|
||||
const editable = $derived(canOperate($page.data?.user?.role));
|
||||
const fleetServers = $derived($sharedFleet);
|
||||
const eligibleFiles = $derived(
|
||||
scripts.filter((file) => file.runningBetaId === null && file.latestVersion !== null)
|
||||
@@ -307,6 +309,7 @@
|
||||
}
|
||||
}
|
||||
async function initialize(ignoreDraft = false) {
|
||||
if (!editable) return;
|
||||
loading = true;
|
||||
flash = '';
|
||||
draftReady = false;
|
||||
@@ -413,7 +416,7 @@
|
||||
if (useLinked) void initialize(true);
|
||||
}
|
||||
$effect(() => {
|
||||
if (!draftReady || loading || openingBeta) return;
|
||||
if (!editable || !draftReady || loading || openingBeta) return;
|
||||
const draft: BetaDraft = {
|
||||
version: 1,
|
||||
savedAt: new Date().toISOString(),
|
||||
@@ -467,6 +470,7 @@
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
if (!editable) return;
|
||||
void initialize();
|
||||
const timer = setInterval(() => (now = serverTimeNow()), 10000);
|
||||
const unload = (event: BeforeUnloadEvent) => {
|
||||
@@ -547,7 +551,7 @@
|
||||
}
|
||||
async function openBeta(event: Event) {
|
||||
event.preventDefault();
|
||||
if (step !== 3 || forwardReason || openingBeta) return;
|
||||
if (!editable || step !== 3 || forwardReason || openingBeta) return;
|
||||
if (betaPlanIssues(testPlan, serverCount).length) {
|
||||
await move(2);
|
||||
await planBuilder?.validate();
|
||||
@@ -606,15 +610,21 @@
|
||||
title="New beta"
|
||||
subtitle="Test files across games in one beta. Choose one instance count for every game."
|
||||
/>
|
||||
{#if flash}<div class="ui-error" role="alert">
|
||||
{#if editable && flash}<div class="ui-error" role="alert">
|
||||
{flash}{#if !scripts.length}<Button size="sm" onclick={() => initialize()}>Retry</Button
|
||||
>{/if}
|
||||
</div>{/if}
|
||||
{#if $fleetError}<div class="ui-error" role="alert">
|
||||
{#if editable && $fleetError}<div class="ui-error" role="alert">
|
||||
{$fleetError}<Button size="sm" onclick={() => refreshFleet(true)}>Retry servers</Button>
|
||||
</div>{/if}
|
||||
{#if notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||
{#if loading}<EmptyState title="Loading beta setup…" />
|
||||
{#if editable && notice}<p role="status" class="notice">{notice}</p>{/if}
|
||||
{#if !editable}<EmptyState
|
||||
title="Beta creation requires operator access"
|
||||
description="You can review existing betas, test results, and saved evidence."
|
||||
>
|
||||
{#snippet actions()}<Button href="/betas">View betas</Button>{/snippet}
|
||||
</EmptyState>
|
||||
{:else if loading}<EmptyState title="Loading beta setup…" />
|
||||
{:else if availableDraft}<section class="ui-panel draft">
|
||||
<h2>Resume unfinished beta?</h2>
|
||||
<p>
|
||||
@@ -776,8 +786,8 @@
|
||||
</p>
|
||||
</div>
|
||||
{:else}<p>
|
||||
<strong>{pickedServers.size} selected</strong> · {candidateServers.length} compatible
|
||||
servers. Restarts wait for confirmed empty lobbies.
|
||||
<strong>{pickedServers.size} selected</strong> · {candidateServers.length}
|
||||
compatible servers. Restarts wait for confirmed empty lobbies.
|
||||
</p>{/if}
|
||||
<details>
|
||||
<summary>Selected file scopes</summary>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
const writable = $derived(canOperate($page.data?.user?.role));
|
||||
import { plural } from '$ui/plural.js';
|
||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
@@ -170,6 +172,7 @@
|
||||
onMount(() => pollVisible(loadDetail, 5000, false));
|
||||
|
||||
async function resume() {
|
||||
if (!writable) return;
|
||||
resuming = true;
|
||||
resumeError = '';
|
||||
try {
|
||||
@@ -188,6 +191,7 @@
|
||||
}
|
||||
|
||||
async function rollback() {
|
||||
if (!writable) return;
|
||||
if (!detail || rollingBack) return;
|
||||
const id = detail.deployment.id;
|
||||
const servers = detail.servers.filter((server) => !server.server_deleted_at).length;
|
||||
@@ -299,7 +303,7 @@
|
||||
$page.url
|
||||
)}>View rollback</Button
|
||||
>
|
||||
{:else if detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some((server) => !server.server_deleted_at)}
|
||||
{:else if writable && detail && action === 'promote' && !detail.fileContentsExpired && detail.servers.some((server) => !server.server_deleted_at)}
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
@@ -380,7 +384,7 @@
|
||||
>All servers activated</span
|
||||
>{/if}
|
||||
</div>
|
||||
{#if detail.servers.some((server) => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
|
||||
{#if writable && detail.servers.some((server) => !server.server_deleted_at && ['held', 'manual'].includes(server.restart_state))}
|
||||
<Button size="sm" onclick={resume} loading={resuming} disabled={resuming}
|
||||
>Resume when empty</Button
|
||||
>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
} from '$ui/maintenance-presentation.js';
|
||||
import { countLabel, plural } from '$ui/plural.js';
|
||||
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
let data = $state<MaintenanceOverview | null>(null);
|
||||
let error = $state('');
|
||||
let notice = $state('');
|
||||
@@ -262,7 +263,7 @@
|
||||
}
|
||||
}
|
||||
async function act(body: Record<string, unknown>) {
|
||||
if (busy) return;
|
||||
if (!administrator || busy) return;
|
||||
busy = true;
|
||||
notice = '';
|
||||
error = '';
|
||||
@@ -296,8 +297,10 @@
|
||||
subtitle="Keep servers healthy, current, and ready for the next match."
|
||||
>
|
||||
{#snippet actions()}
|
||||
<Button disabled={busy} onclick={() => act({ action: 'scan' })}>Check now</Button>
|
||||
{#if data}<Button
|
||||
{#if administrator}<Button disabled={busy} onclick={() => act({ action: 'scan' })}
|
||||
>Check now</Button
|
||||
>{/if}
|
||||
{#if administrator && data}<Button
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onclick={() => act({ action: 'pause', paused: !data!.policies.paused })}
|
||||
@@ -349,17 +352,17 @@
|
||||
>
|
||||
</div>
|
||||
<p>{policy.description}</p>
|
||||
<Button
|
||||
disabled={busy}
|
||||
onclick={() =>
|
||||
act({
|
||||
action: 'policy',
|
||||
policy: policy.id,
|
||||
enabled: !data!.policies[policy.id]
|
||||
})}
|
||||
>{data.policies[policy.id] ? 'Disable' : 'Enable'}
|
||||
{policy.title.toLowerCase()}</Button
|
||||
>
|
||||
{#if administrator}<Button
|
||||
disabled={busy}
|
||||
onclick={() =>
|
||||
act({
|
||||
action: 'policy',
|
||||
policy: policy.id,
|
||||
enabled: !data!.policies[policy.id]
|
||||
})}
|
||||
>{data.policies[policy.id] ? 'Disable' : 'Enable'}
|
||||
{policy.title.toLowerCase()}</Button
|
||||
>{/if}
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
@@ -382,13 +385,24 @@
|
||||
}}
|
||||
>
|
||||
<label class="toggle"
|
||||
><input type="checkbox" bind:checked={scheduleEnabled} /> Daily fleet restart</label
|
||||
><input
|
||||
type="checkbox"
|
||||
bind:checked={scheduleEnabled}
|
||||
disabled={!administrator || busy}
|
||||
/> Daily fleet restart</label
|
||||
>
|
||||
<Field
|
||||
>{#snippet label()}Queue time ({$timeZone}){/snippet}
|
||||
<Input type="time" bind:value={scheduleTime} required /></Field
|
||||
<Input
|
||||
type="time"
|
||||
bind:value={scheduleTime}
|
||||
required
|
||||
disabled={!administrator || busy}
|
||||
/></Field
|
||||
>
|
||||
<Button type="submit" disabled={busy}>Save schedule</Button>
|
||||
{#if administrator}<Button type="submit" disabled={busy}
|
||||
>Save schedule</Button
|
||||
>{/if}
|
||||
</form>
|
||||
<p class="muted">
|
||||
Saving follows daylight saving changes in your selected timezone. Skipped
|
||||
@@ -444,11 +458,11 @@
|
||||
<section class="scan-errors" aria-labelledby="scan-errors-heading">
|
||||
<div class="row-heading">
|
||||
<h2 id="scan-errors-heading">Checks need attention</h2>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() => act({ action: 'scan' })}>Retry checks</Button
|
||||
>
|
||||
{#if administrator}<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() => act({ action: 'scan' })}>Retry checks</Button
|
||||
>{/if}
|
||||
</div>
|
||||
<ul class="error-groups">
|
||||
{#each scanErrors as group (group.key)}
|
||||
@@ -520,7 +534,7 @@
|
||||
<section class="card" id="updates">
|
||||
<div class="row-heading">
|
||||
<h2>Plutonium updates</h2>
|
||||
{#if updatableServers.length}<Button
|
||||
{#if administrator && updatableServers.length}<Button
|
||||
disabled={busy || data.policies.paused}
|
||||
onclick={() =>
|
||||
act({ action: 'update', servers: updatableServers })}
|
||||
@@ -575,13 +589,13 @@
|
||||
{#if group.pending.length}<span
|
||||
>{countLabel(group.pending.length, 'restart')} pending</span
|
||||
>{/if}
|
||||
{#if group.status === 'unknown'}<Button
|
||||
{#if administrator && group.status === 'unknown'}<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() => act({ action: 'scan' })}
|
||||
>Retry version check</Button
|
||||
>
|
||||
{:else if group.updatable.length}<Button
|
||||
{:else if administrator && group.updatable.length}<Button
|
||||
size="sm"
|
||||
disabled={busy || data.policies.paused}
|
||||
onclick={() =>
|
||||
@@ -692,14 +706,14 @@
|
||||
? 'View restart operation'
|
||||
: 'View evidence and recovery'}</a
|
||||
>{/if}
|
||||
{#if item.job?.state === 'queued' && !item.server_deleted_at}<Button
|
||||
{#if administrator && item.job?.state === 'queued' && !item.server_deleted_at}<Button
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onclick={() =>
|
||||
act({ action: 'cancel', id: item.job!.id })}
|
||||
>Cancel restart</Button
|
||||
>{/if}
|
||||
{#if item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button
|
||||
{#if administrator && item.job?.state === 'failed' && !item.server_deleted_at && item.job.operation_state === 'done'}<Button
|
||||
size="sm"
|
||||
disabled={busy || data.policies.paused}
|
||||
onclick={() =>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import type { WatchdogDetail, WatchdogEvidence } from '$lib/watchdog.js';
|
||||
import { pollVisible } from '$ui/poll.js';
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
@@ -24,6 +25,7 @@
|
||||
let eventLimit = $state(25);
|
||||
let fileLimit = $state(25);
|
||||
let request = 0;
|
||||
const operator = $derived(canOperate($page.data.user?.role));
|
||||
const incident = $derived(detail?.incident);
|
||||
const evidence = $derived.by((): WatchdogEvidence => {
|
||||
try {
|
||||
@@ -44,11 +46,13 @@
|
||||
)
|
||||
);
|
||||
const canRetry = $derived(
|
||||
!incident?.server_deleted_at &&
|
||||
operator &&
|
||||
!incident?.server_deleted_at &&
|
||||
(incident?.state === 'attention' || incident?.state === 'dismissed')
|
||||
);
|
||||
const canDismiss = $derived(
|
||||
!incident?.server_deleted_at &&
|
||||
operator &&
|
||||
!incident?.server_deleted_at &&
|
||||
(incident?.state === 'open' || incident?.state === 'attention')
|
||||
);
|
||||
const explanation = $derived.by(() => {
|
||||
@@ -92,7 +96,7 @@
|
||||
}
|
||||
|
||||
async function update(action: 'retry' | 'dismiss') {
|
||||
if (busy || !incident) return;
|
||||
if (!operator || busy || !incident) return;
|
||||
const id = incident.id;
|
||||
const current = ++request;
|
||||
busy = action;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
@@ -20,6 +21,7 @@
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
const editable = $derived(canOperate($page.data?.user?.role));
|
||||
|
||||
type ArtifactKind = 'script' | 'config' | 'plugin' | 'baseline';
|
||||
interface ArtifactRow {
|
||||
@@ -266,11 +268,11 @@
|
||||
title="Plugins"
|
||||
subtitle="Manage plugin versions and test changes before promotion."
|
||||
>
|
||||
{#snippet actions()}{#if plugins.length}<Button
|
||||
{#snippet actions()}{#if editable && plugins.length}<Button
|
||||
variant="ghost"
|
||||
onclick={() => (updatesOpen = !updatesOpen)}
|
||||
>{updatesOpen ? 'Hide update details' : 'Check for updates'}</Button
|
||||
>{/if}{#if loading || plugins.length || flash}<Button
|
||||
>{/if}{#if editable && (loading || plugins.length || flash)}<Button
|
||||
variant="primary"
|
||||
onclick={() => (addOpen = true)}>Add plugin</Button
|
||||
>{/if}{/snippet}
|
||||
@@ -339,7 +341,7 @@
|
||||
{#if game}<p class="filter-hint">
|
||||
Game results include unscoped plugins that can use explicitly selected beta servers.
|
||||
</p>{/if}
|
||||
{#if updatesOpen}<details
|
||||
{#if editable && updatesOpen}<details
|
||||
open
|
||||
ontoggle={(event) => {
|
||||
if (!event.currentTarget.open) updatesOpen = false;
|
||||
@@ -377,8 +379,9 @@
|
||||
game = '';
|
||||
status = '';
|
||||
}}>Clear filters</Button
|
||||
>{:else}<Button variant="primary" onclick={() => (addOpen = true)}
|
||||
>Add plugin</Button
|
||||
>{:else if editable}<Button
|
||||
variant="primary"
|
||||
onclick={() => (addOpen = true)}>Add plugin</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>
|
||||
{:else}
|
||||
@@ -449,91 +452,95 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'upload', label: 'Upload file', disabled: uploading || githubBusy },
|
||||
{ id: 'github', label: 'GitHub release', disabled: uploading || githubBusy }
|
||||
]}
|
||||
bind:value={method}
|
||||
ariaLabel="Plugin source"
|
||||
idPrefix="plugin-source"
|
||||
panelId="plugin-source-panel"
|
||||
/>
|
||||
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
|
||||
{#if method === 'github'}<PluginGithubImport
|
||||
bind:busy={githubBusy}
|
||||
onimport={async (result) => {
|
||||
importComplete = result;
|
||||
updates = updates.filter((update) => update.pluginId !== result.pluginId);
|
||||
importCount++;
|
||||
addOpen = false;
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<form onsubmit={createPlugin} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
|
||||
>
|
||||
<Input
|
||||
bind:value={newReleaseVersion}
|
||||
placeholder="v3.1.0"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
||||
</Field>
|
||||
<Field label="Scope" hint="Where this plugin deploys.">
|
||||
<div class="scope-row">
|
||||
<Select
|
||||
ariaLabel="Scope type"
|
||||
bind:value={newScopeType}
|
||||
options={scopeTypeOptions}
|
||||
{#if editable}<Dialog bind:open={addOpen} title="Add plugin" busy={uploading || githubBusy} wide>
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'upload', label: 'Upload file', disabled: uploading || githubBusy },
|
||||
{ id: 'github', label: 'GitHub release', disabled: uploading || githubBusy }
|
||||
]}
|
||||
bind:value={method}
|
||||
ariaLabel="Plugin source"
|
||||
idPrefix="plugin-source"
|
||||
panelId="plugin-source-panel"
|
||||
/>
|
||||
<div id="plugin-source-panel" role="tabpanel" aria-labelledby={'plugin-source-' + method}>
|
||||
{#if method === 'github'}<PluginGithubImport
|
||||
bind:busy={githubBusy}
|
||||
onimport={async (result) => {
|
||||
importComplete = result;
|
||||
updates = updates.filter((update) => update.pluginId !== result.pluginId);
|
||||
importCount++;
|
||||
addOpen = false;
|
||||
await load();
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<form onsubmit={createPlugin} class="upload-form">
|
||||
<Field label="Name">
|
||||
<Input bind:value={newName} placeholder="my plugin" disabled={uploading} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Release version"
|
||||
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
|
||||
>
|
||||
<Input
|
||||
bind:value={newReleaseVersion}
|
||||
placeholder="v3.1.0"
|
||||
disabled={uploading}
|
||||
/>
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
</Field>
|
||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
||||
<Input
|
||||
bind:value={newNotes}
|
||||
placeholder="what changed"
|
||||
disabled={uploading}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Scope" hint="Where this plugin deploys.">
|
||||
<div class="scope-row">
|
||||
<Select
|
||||
ariaLabel="Scope game"
|
||||
bind:value={newScopeGame}
|
||||
onchange={() => (newScopeMap = '')}
|
||||
options={gameOptions}
|
||||
ariaLabel="Scope type"
|
||||
bind:value={newScopeType}
|
||||
options={scopeTypeOptions}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select
|
||||
ariaLabel="Scope map"
|
||||
bind:value={newScopeMap}
|
||||
options={mapOptions}
|
||||
disabled={uploading || !newScopeGame}
|
||||
/>
|
||||
{/if}
|
||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
||||
<Select
|
||||
ariaLabel="Scope game"
|
||||
bind:value={newScopeGame}
|
||||
onchange={() => (newScopeMap = '')}
|
||||
options={gameOptions}
|
||||
disabled={uploading}
|
||||
/>
|
||||
{/if}
|
||||
{#if newScopeType === 'map'}
|
||||
<Select
|
||||
ariaLabel="Scope map"
|
||||
bind:value={newScopeMap}
|
||||
options={mapOptions}
|
||||
disabled={uploading || !newScopeGame}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Plugin file (.dll)">
|
||||
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
|
||||
</Field>
|
||||
<div class="form-actions ui-sticky-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !newFile || !newName}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload plugin
|
||||
</Button>
|
||||
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
|
||||
</div>
|
||||
</Field>
|
||||
<Field label="Plugin file (.dll)">
|
||||
<Input type="file" accept=".dll" onchange={pickFile} disabled={uploading} />
|
||||
</Field>
|
||||
<div class="form-actions ui-sticky-actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={uploading || !newFile || !newName}
|
||||
loading={uploading}
|
||||
>
|
||||
Upload plugin
|
||||
</Button>
|
||||
{#if flash}<span class="flash" role="alert">{flash}</span>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</Dialog>{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
const writable = $derived(canOperate($page.data?.user?.role));
|
||||
import Select from '$ui/components/primitives/Select.svelte';
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
@@ -100,7 +102,7 @@
|
||||
void goto(url, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
async function cancelRequests(rows: Deployment[], bulk = false) {
|
||||
if (cancelling || !rows.length) return;
|
||||
if (!writable || cancelling || !rows.length) return;
|
||||
const ids = rows.map((row) => row.id);
|
||||
if (
|
||||
!(await confirmAction({
|
||||
@@ -288,10 +290,10 @@
|
||||
<svelte:head><title>Provisioning | Electric Cherry</title></svelte:head>
|
||||
<div class="page">
|
||||
<SectionHeader title="Provisioning">
|
||||
{#snippet actions()}
|
||||
<Button onclick={() => (lifecycleOpen = true)}>Server actions</Button>
|
||||
<Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button>
|
||||
{/snippet}
|
||||
{#snippet actions()}{#if writable}
|
||||
<Button onclick={() => (lifecycleOpen = true)}>Server actions</Button>
|
||||
<Button onclick={() => (fleetActionsOpen = true)}>Fleet actions</Button>
|
||||
{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
{#if $page.url.searchParams.has('returnTo')}<BackLink fallback="/" />{/if}
|
||||
<div class="toolbar">
|
||||
@@ -368,16 +370,16 @@
|
||||
<Button variant="primary" onclick={() => (filtersOpen = false)}>Show results</Button>
|
||||
{/snippet}
|
||||
</Dialog>
|
||||
<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
<FleetActionsDialog
|
||||
bind:open={fleetActionsOpen}
|
||||
onqueued={(count, action) => {
|
||||
fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`;
|
||||
tab = 'active';
|
||||
clearFilters();
|
||||
void load();
|
||||
}}
|
||||
/>
|
||||
{#if writable}<LifecycleDialog bind:open={lifecycleOpen} />
|
||||
<FleetActionsDialog
|
||||
bind:open={fleetActionsOpen}
|
||||
onqueued={(count, action) => {
|
||||
fleetNotice = `${actionLabels[action]} queued for ${count} server${count === 1 ? '' : 's'}.`;
|
||||
tab = 'active';
|
||||
clearFilters();
|
||||
void load();
|
||||
}}
|
||||
/>{/if}
|
||||
{#if fleetNotice}<p role="status">{fleetNotice}</p>{/if}
|
||||
{#if cancellationNotice}<p role="status">{cancellationNotice}</p>{/if}
|
||||
<div
|
||||
@@ -398,7 +400,7 @@
|
||||
</p>
|
||||
{:else}<h2>History <span>{historyTotal}</span></h2>{/if}
|
||||
<div class="queue-tools">
|
||||
{#if tab === 'active' && active.some((row) => row.canCancel)}<Button
|
||||
{#if writable && tab === 'active' && active.some((row) => row.canCancel)}<Button
|
||||
size="sm"
|
||||
disabled={cancelling}
|
||||
onclick={() =>
|
||||
@@ -595,7 +597,7 @@
|
||||
`/servers/${encodeURIComponent(row.server)}?tab=logs`,
|
||||
$page.url
|
||||
)}>Open logs</a
|
||||
>{/if}{#if row.canCancel && !row.server_deleted_at}<Button
|
||||
>{/if}{#if writable && row.canCancel && !row.server_deleted_at}<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
disabled={cancelling}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onDestroy, untrack } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
@@ -35,6 +36,7 @@
|
||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
const writable = $derived(canOperate(data.user?.role));
|
||||
const initial = untrack(() => data.detail);
|
||||
let view = $state<ProvisioningRefreshState<ProvisioningDetail>>({
|
||||
id: initial.deployment.id,
|
||||
@@ -104,6 +106,7 @@
|
||||
});
|
||||
onDestroy(() => controller.dispose());
|
||||
async function retryWithRebuild() {
|
||||
if (!writable) return;
|
||||
if (!deployment || !detail?.canRetryWithRebuild || view.busy) return;
|
||||
const id = deployment.id;
|
||||
const confirmed = await confirmAction({
|
||||
@@ -138,6 +141,7 @@
|
||||
);
|
||||
}
|
||||
async function cancelOperation() {
|
||||
if (!writable) return;
|
||||
if (!deployment || !detail?.canCancel || view.busy) return;
|
||||
const id = deployment.id;
|
||||
const confirmed = await confirmAction({
|
||||
@@ -153,6 +157,7 @@
|
||||
);
|
||||
}
|
||||
async function cancelCreation() {
|
||||
if (!writable) return;
|
||||
if (!deployment || !detail?.canCancelCreation || view.busy) return;
|
||||
const id = deployment.id;
|
||||
const confirmed = await confirmAction({
|
||||
@@ -305,7 +310,7 @@
|
||||
)}.
|
||||
</p>
|
||||
</div>{/if}
|
||||
{#if detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride}
|
||||
{#if writable && (detail.canResumeVerification || detail.canResumeCleanup || detail.canResumeRebuild || detail.canRetryWithRebuild || detail.canCancel || detail.canCancelCreation || canOverride)}
|
||||
<div class="recovery-actions">
|
||||
{#if canOverride}<div>
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import Input from '$ui/components/primitives/Input.svelte';
|
||||
import Field from '$ui/components/primitives/Field.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canOperate } from '$lib/auth.js';
|
||||
import { contextHref } from '$lib/navigation-context.js';
|
||||
import { onMount } from 'svelte';
|
||||
import { afterNavigate } from '$app/navigation';
|
||||
@@ -24,6 +25,7 @@
|
||||
} from '$lib/script-library.js';
|
||||
import { zombieMaps } from '$lib/map-aliases.js';
|
||||
import { libraryLimit, saveLibraryContext } from '$ui/library-context.js';
|
||||
const editable = $derived(canOperate($page.data?.user?.role));
|
||||
|
||||
let limit = $state(25);
|
||||
let mounted = $state(false);
|
||||
@@ -53,7 +55,7 @@
|
||||
let map = $state('');
|
||||
let sort = $state('attention');
|
||||
let selected = $state<number[]>([]);
|
||||
let uploader: ScriptUploadDrawer;
|
||||
let uploader = $state<ScriptUploadDrawer>();
|
||||
let dragDepth = $state(0);
|
||||
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
|
||||
{ value: 'all', label: 'All scripts' },
|
||||
@@ -179,13 +181,15 @@
|
||||
map = '';
|
||||
}
|
||||
function onDrag(event: DragEvent) {
|
||||
if (!editable) return;
|
||||
if (event.dataTransfer?.types.includes('Files')) event.preventDefault();
|
||||
}
|
||||
function onDrop(event: DragEvent) {
|
||||
if (!editable) return;
|
||||
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||
event.preventDefault();
|
||||
dragDepth = 0;
|
||||
void uploader.addFiles([...event.dataTransfer.files]);
|
||||
void uploader?.addFiles([...event.dataTransfer.files]);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -195,7 +199,7 @@
|
||||
ondragover={onDrag}
|
||||
ondrop={onDrop}
|
||||
ondragenter={(event) => {
|
||||
if (event.dataTransfer?.types.includes('Files')) {
|
||||
if (editable && event.dataTransfer?.types.includes('Files')) {
|
||||
event.preventDefault();
|
||||
dragDepth++;
|
||||
}
|
||||
@@ -209,9 +213,9 @@
|
||||
title="Scripts"
|
||||
subtitle="Review versions, prepare a beta, and keep releases moving."
|
||||
>
|
||||
{#snippet actions()}{#if loading || scripts.length || error}<Button
|
||||
{#snippet actions()}{#if editable && (loading || scripts.length || error)}<Button
|
||||
variant="primary"
|
||||
onclick={() => uploader.open()}>Upload scripts</Button
|
||||
onclick={() => uploader?.open()}>Upload scripts</Button
|
||||
>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<section class="library" aria-label="Script library">
|
||||
@@ -277,9 +281,10 @@
|
||||
title="No scripts yet"
|
||||
description="Upload script files to keep versions and test changes in a beta before promotion."
|
||||
>
|
||||
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}
|
||||
>Upload scripts</Button
|
||||
>{/snippet}
|
||||
{#snippet actions()}{#if editable}<Button
|
||||
variant="primary"
|
||||
onclick={() => uploader?.open()}>Upload scripts</Button
|
||||
>{/if}{/snippet}
|
||||
</EmptyState>
|
||||
{:else if !visible.length && !error}
|
||||
<EmptyState
|
||||
@@ -293,31 +298,32 @@
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th class="select-cell"
|
||||
><input
|
||||
type="checkbox"
|
||||
aria-label="Select visible scripts eligible for a beta"
|
||||
checked={allSelected}
|
||||
disabled={!selectable.length}
|
||||
onchange={selectVisible}
|
||||
/></th
|
||||
><th>Script</th><th>Game / scope</th><th>Version / status</th><th
|
||||
>Eligible servers</th
|
||||
><th><span class="sr-only">Next action</span></th></tr
|
||||
>{#if editable}<th class="select-cell"
|
||||
><input
|
||||
type="checkbox"
|
||||
aria-label="Select visible scripts eligible for a beta"
|
||||
checked={allSelected}
|
||||
disabled={!selectable.length}
|
||||
onchange={selectVisible}
|
||||
/></th
|
||||
>{/if}<th>Script</th><th>Game / scope</th><th>Version / status</th
|
||||
><th>Eligible servers</th><th
|
||||
><span class="sr-only">Next action</span></th
|
||||
></tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
>{#each visible.slice(0, limit) as s (s.id)}
|
||||
<tr class:selected={selected.includes(s.id)}>
|
||||
<td class="select-cell"
|
||||
><input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${s.name} for a beta`}
|
||||
checked={selected.includes(s.id)}
|
||||
disabled={!canTest(s)}
|
||||
onchange={() => toggle(s.id)}
|
||||
/></td
|
||||
>
|
||||
{#if editable}<td class="select-cell"
|
||||
><input
|
||||
type="checkbox"
|
||||
aria-label={`Select ${s.name} for a beta`}
|
||||
checked={selected.includes(s.id)}
|
||||
disabled={!canTest(s)}
|
||||
onchange={() => toggle(s.id)}
|
||||
/></td
|
||||
>{/if}
|
||||
<td
|
||||
><a
|
||||
class="name"
|
||||
@@ -349,7 +355,8 @@
|
||||
href={contextHref(`/scripts/${s.id}`, $page.url)}
|
||||
>Review failure</a
|
||||
>
|
||||
{:else if canTest(s)}<a href={betaHref([s])}>Create beta</a
|
||||
{:else if editable && canTest(s)}<a href={betaHref([s])}
|
||||
>Create beta</a
|
||||
>{/if}</td
|
||||
>
|
||||
</tr>
|
||||
@@ -360,7 +367,7 @@
|
||||
{/if}
|
||||
<ListFooter total={visible.length} bind:limit label="scripts" />
|
||||
</section>
|
||||
{#if selectedScripts.length}
|
||||
{#if editable && selectedScripts.length}
|
||||
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||
<strong
|
||||
title={hiddenSelected
|
||||
@@ -398,7 +405,7 @@
|
||||
>We’ll recognize existing scripts and check for duplicate versions.</span
|
||||
>
|
||||
</div>{/if}
|
||||
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
|
||||
{#if editable}<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
updated_at: string;
|
||||
}
|
||||
const games: ZombieGame[] = ['t5', 't6'];
|
||||
const administrator = $derived($page.data.user?.role === 'admin');
|
||||
let open = $state(false);
|
||||
let search = $state('');
|
||||
let gameFilter = $state('');
|
||||
@@ -85,6 +86,7 @@
|
||||
void reload();
|
||||
});
|
||||
function addKey() {
|
||||
if (!administrator) return;
|
||||
plutoKey = '';
|
||||
label = '';
|
||||
error = '';
|
||||
@@ -93,7 +95,7 @@
|
||||
}
|
||||
async function save(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!plutoKey.trim() || busy) return;
|
||||
if (!administrator || !plutoKey.trim() || busy) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
notice = '';
|
||||
@@ -117,7 +119,7 @@
|
||||
}
|
||||
}
|
||||
async function remove(key: SavedKey) {
|
||||
if (key.assigned_servers.length) return;
|
||||
if (!administrator || busy || key.assigned_servers.length) return;
|
||||
if (
|
||||
!(await confirmAction({
|
||||
title: 'Remove saved key?',
|
||||
@@ -154,7 +156,9 @@
|
||||
title="Keys"
|
||||
subtitle="Pool Plutonium keys by game. Any available key can launch any map or lobby in that game."
|
||||
>
|
||||
{#snippet actions()}<Button variant="primary" onclick={addKey}>Add key</Button>{/snippet}
|
||||
{#snippet actions()}{#if administrator}<Button variant="primary" onclick={addKey}
|
||||
>Add key</Button
|
||||
>{/if}{/snippet}
|
||||
</SectionHeader>
|
||||
<p class="key-help">
|
||||
A key stays assigned while its server exists, including when stopped, and becomes available
|
||||
@@ -209,16 +213,17 @@
|
||||
{#if loading}<p>Loading keys…</p>
|
||||
{:else if !filtered.length}<EmptyState
|
||||
title={keys.length ? 'No matching keys' : 'No saved keys'}
|
||||
description="Add keys here. Launches automatically use an available key from their game’s pool."
|
||||
description={administrator
|
||||
? 'Add keys here. Launches automatically use an available key from their game’s pool.'
|
||||
: 'Launches automatically use an available key from their game’s pool.'}
|
||||
/>
|
||||
{:else}
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead
|
||||
><tr
|
||||
><th>Game / label</th><th>Availability</th><th>Last saved</th><th
|
||||
>Actions</th
|
||||
></tr
|
||||
><th>Game / label</th><th>Availability</th><th>Last saved</th
|
||||
>{#if administrator}<th>Actions</th>{/if}</tr
|
||||
></thead
|
||||
>
|
||||
<tbody
|
||||
@@ -237,13 +242,13 @@
|
||||
>{/if}</td
|
||||
>
|
||||
<td><RelativeTime value={key.updated_at} /></td>
|
||||
<td
|
||||
><Button
|
||||
ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`}
|
||||
disabled={busy || !!key.assigned_servers.length}
|
||||
onclick={() => remove(key)}>Remove</Button
|
||||
>{#if key.assigned_servers.length}<small>In use</small>{/if}</td
|
||||
>
|
||||
{#if administrator}<td
|
||||
><Button
|
||||
ariaLabel={`Remove ${key.label || key.game.toUpperCase() + ' key'}`}
|
||||
disabled={busy || !!key.assigned_servers.length}
|
||||
onclick={() => remove(key)}>Remove</Button
|
||||
>{#if key.assigned_servers.length}<small>In use</small>{/if}</td
|
||||
>{/if}
|
||||
</tr>
|
||||
{/each}</tbody
|
||||
>
|
||||
@@ -252,44 +257,44 @@
|
||||
{/if}
|
||||
<ListFooter total={filtered.length} bind:limit onchange={saveSearch} label="keys" />
|
||||
</div>
|
||||
<Dialog bind:open title="Add key" {busy}>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<form onsubmit={save}>
|
||||
<Field label="Game">
|
||||
<Select bind:value={game} disabled={busy}
|
||||
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
|
||||
></Select
|
||||
></Field
|
||||
>
|
||||
<Field
|
||||
label="Label (optional)"
|
||||
hint="Record the hostname you chose in Plutonium, or another name that helps identify this key."
|
||||
>
|
||||
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Plutonium server key"
|
||||
hint="Available to any new lobby in this game. Existing credentials are never displayed."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
bind:value={plutoKey}
|
||||
placeholder="Paste key"
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</Field>
|
||||
<div class="actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={busy || loading || !plutoKey.trim()}
|
||||
loading={busy}>Save key</Button
|
||||
><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>
|
||||
{#if administrator}<Dialog bind:open title="Add key" {busy}>
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<form onsubmit={save}>
|
||||
<Field label="Game">
|
||||
<Select bind:value={game} disabled={busy}
|
||||
><option value="t5">Black Ops I</option><option value="t6">Black Ops II</option
|
||||
></Select
|
||||
></Field
|
||||
>
|
||||
<Field
|
||||
label="Label (optional)"
|
||||
hint="Record the hostname you chose in Plutonium, or another name that helps identify this key."
|
||||
>
|
||||
<Input bind:value={label} placeholder="e.g. Electric Cherry 5" disabled={busy} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Plutonium server key"
|
||||
hint="Available to any new lobby in this game. Existing credentials are never displayed."
|
||||
>
|
||||
<Input
|
||||
type="password"
|
||||
bind:value={plutoKey}
|
||||
placeholder="Paste key"
|
||||
autocomplete="off"
|
||||
required
|
||||
disabled={busy}
|
||||
/>
|
||||
</Field>
|
||||
<div class="actions">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={busy || loading || !plutoKey.trim()}
|
||||
loading={busy}>Save key</Button
|
||||
><Button onclick={() => (open = false)} disabled={busy}>Cancel</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog>{/if}
|
||||
|
||||
<style>
|
||||
.page {
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import StatusBadge from '$ui/components/primitives/StatusBadge.svelte';
|
||||
import ListFooter from '$ui/components/primitives/ListFooter.svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { canReadSensitive } from '$lib/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
import LifecycleDialog from '$ui/components/LifecycleDialog.svelte';
|
||||
import { actionLabels, type RuntimeAction } from '$lib/provisioning.js';
|
||||
@@ -120,6 +121,7 @@
|
||||
const canOperate = $derived(
|
||||
$page.data?.user?.role === 'admin' || $page.data?.user?.role === 'operator'
|
||||
);
|
||||
const canInspect = $derived(canReadSensitive($page.data?.user?.role));
|
||||
const availableActions = $derived(
|
||||
serverContext ? applicableRuntimeActions(serverContext, $uiNow) : []
|
||||
);
|
||||
@@ -207,7 +209,7 @@
|
||||
|
||||
let tabSequence = 0;
|
||||
async function loadTab(currentName: string, tab: string, signal?: AbortSignal) {
|
||||
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
|
||||
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canInspect)) return;
|
||||
const sequence = ++tabSequence;
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -231,7 +233,7 @@
|
||||
)
|
||||
return;
|
||||
if (tab === 'diagnostics') {
|
||||
if (!canOperate) return;
|
||||
if (!canInspect) return;
|
||||
savedRconSnippet = body.replySnippet;
|
||||
} else inventory = body.scripts;
|
||||
tabError = '';
|
||||
@@ -262,8 +264,8 @@
|
||||
inventory = [];
|
||||
inventoryLimit = 25;
|
||||
savedRconSnippet = null;
|
||||
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canOperate);
|
||||
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canOperate)) return;
|
||||
tabLoading = tab === 'configuration' || (tab === 'diagnostics' && canInspect);
|
||||
if (tab !== 'configuration' && (tab !== 'diagnostics' || !canInspect)) return;
|
||||
return pollVisible((signal) => loadTab(currentName, tab, signal), 10000);
|
||||
});
|
||||
$effect(() => {
|
||||
@@ -290,7 +292,7 @@
|
||||
let effectiveReason = $derived<RconFailure | null>(decoded?.reason ?? null);
|
||||
let errorDetail = $derived(decoded?.detail ?? effectiveErr ?? '');
|
||||
let effectiveSnippet = $derived<string | null>(
|
||||
canOperate ? (rconProbe ? (rconProbe.rawReply ?? null) : savedRconSnippet) : null
|
||||
canInspect ? (rconProbe ? (rconProbe.rawReply ?? null) : savedRconSnippet) : null
|
||||
);
|
||||
let effectiveActiveSince = $derived<string | null>(server?.active_since ?? null);
|
||||
let effectiveInactiveSince = $derived<string | null>(server?.inactive_since ?? null);
|
||||
@@ -705,7 +707,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canOperate && effectiveSnippet}
|
||||
{#if canInspect && effectiveSnippet}
|
||||
<details class="snippet-details" open={effectiveReason === 'bad-reply'}>
|
||||
<summary>Failure reply (addresses redacted)</summary>
|
||||
<pre class="snippet">{effectiveSnippet}</pre>
|
||||
|
||||
@@ -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