Add Super Viewer read-only administrator role
CI / checks (push) Successful in 5m40s

This commit is contained in:
2026-10-11 23:30:40 -04:00
parent 253d47300c
commit 4ea291d305
48 changed files with 1684 additions and 933 deletions
+184 -176
View File
@@ -2,6 +2,7 @@
import { serverTitle } from '$ui/server-labels.js';
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 { onMount } from 'svelte';
import { pollVisible } from '$ui/poll.js';
@@ -33,6 +34,7 @@
family: Family;
}
let { id, family }: Props = $props();
const editable = $derived(canOperate($page.data?.user?.role));
const basePath = $derived(`/${family}s`);
const crumbLabel = $derived(family.charAt(0).toUpperCase() + family.slice(1) + 's');
@@ -228,7 +230,7 @@
onMount(() => pollVisible(() => load({ seedForm: false }), 10000, false));
function openScopeModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
seedScopeForm();
flash = null;
scopeReviewOpen = true;
@@ -240,7 +242,7 @@
scopeDialog?.close();
}
function openUploadModal(): void {
if (runningBetaId !== null) return;
if (!editable || runningBetaId !== null) return;
releaseVersion = '';
versionFile = null;
versionNotes = '';
@@ -273,7 +275,7 @@
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!versionFile || uploadingVersion) return;
if (!editable || !versionFile || uploadingVersion) return;
uploadingVersion = true;
flash = null;
const form = new FormData();
@@ -304,7 +306,7 @@
async function saveScope(e: Event): Promise<void> {
e.preventDefault();
if (updatingScope || !scopeImpact || scopePreviewLoading) return;
if (!editable || updatingScope || !scopeImpact || scopePreviewLoading) return;
if (scopeType !== 'none' && !editedScopeValue) {
flash = `Pick a ${scopeType}`;
return;
@@ -351,6 +353,7 @@
}
async function deleteArtifact(): Promise<void> {
if (!editable) return;
if (
!(await confirmAction({
title: 'Delete ' + family + '?',
@@ -428,13 +431,15 @@
variant="primary"
href={contextHref(`/betas/${runningBetaId}`, $page.url)}
>View active beta</Button
>{:else if latestVersion.id !== a.current_version_id}<Button
>{:else if editable && latestVersion.id !== a.current_version_id}<Button
variant="primary"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: latestVersion.id }]),
$page.url
)}>Test latest version</Button
>{:else}<span class="dim">Latest version is promoted</span>{/if}
>{:else if latestVersion.id === a.current_version_id}<span class="dim"
>Latest version is promoted</span
>{/if}
</div></Card
>{/if}
@@ -450,24 +455,24 @@
<Card padding="lg">
<div class="panel-head">
<h2>Details</h2>
<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>
{#if editable}<div class="head-actions">
<Button
variant="secondary"
size="sm"
onclick={openScopeModal}
disabled={runningBetaId !== null}
>
Edit scope
</Button>
<Button
variant="secondary"
size="sm"
onclick={openUploadModal}
disabled={runningBetaId !== null}
>
Upload new version
</Button>
</div>{/if}
</div>
<details class="file-settings">
@@ -584,7 +589,7 @@
<Badge variant="online" size="sm">Promoted</Badge>
{:else}
<StatusBadge state={v.state} size="sm" />
{#if runningBetaId === null}<Button
{#if editable && runningBetaId === null}<Button
size="sm"
href={contextHref(
betaSelectionHref([{ scriptId: id, versionId: v.id }]),
@@ -604,7 +609,7 @@
<ListFooter total={versions.length} bind:limit label="versions" />
</Card>
{#if family === 'plugin'}
{#if editable && family === 'plugin'}
<div id="github-releases">
<PluginGithubImport
pluginId={id}
@@ -615,170 +620,173 @@
</div>
{/if}
<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>
{#if editable}<Card padding="lg">
<div class="panel-head"><h2>Danger zone</h2></div>
<Button variant="danger" onclick={deleteArtifact}>Delete {family}</Button>
</Card>{/if}
{#if flash}<div class="alert" role="alert">{flash}</div>{/if}
<!-- Scope dialog -->
<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
{#if editable}<dialog
aria-label="Edit scope"
onclose={() => (scopeReviewOpen = false)}
oncancel={(event) => {
if (updatingScope) event.preventDefault();
}}
bind:this={scopeDialog}
onclick={(e) => onDialogClick(scopeDialog, e)}
>
<form class="dlg" onsubmit={saveScope}>
<header class="dlg-head">
<h3>Edit scope</h3>
<button
type="button"
class="close"
disabled={updatingScope}
/>
{#if scopeType === 'game' || scopeType === 'map'}
onclick={cancelScopeModal}
aria-label="Close">×</button
>
</header>
<Field label="Scope">
<div class="scope-row">
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
ariaLabel="Scope type"
bind:value={scopeType}
options={scopeTypeOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
{#if scopeType === 'game' || scopeType === 'map'}
<Select
ariaLabel="Scope game"
bind:value={scopeGame}
onchange={() => (scopeMap = '')}
options={gameOptions}
disabled={updatingScope}
/>
{/if}
{#if scopeType === 'map'}
<Select
ariaLabel="Scope map"
bind:value={scopeMap}
options={mapOptions}
disabled={updatingScope || !scopeGame}
/>
{/if}
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added
·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
</Field>
{#if scopePreviewLoading}<p role="status">
Reviewing eligible servers…
</p>{:else if scopePreviewError}<p role="alert">
{scopePreviewError}
</p>{:else if scopeImpact}<section
class="scope-impact"
aria-label="Scope change review"
>
<strong
>{scopeImpact.after.length} eligible · {scopeImpact.added.length} added ·
{scopeImpact.removed.length} removed</strong
>{#if scopeImpact.added.length}<p>
Added: {scopeImpact.added.join(', ')}
</p>{/if}{#if scopeImpact.removed.length}<p>
Removed: {scopeImpact.removed.join(', ')}
</p>{/if}
<p>
{scopeImpact.composed
? 'This changes intended configuration composition.'
: scopeType === 'none'
? `${scopeImpact.detached.length} recorded installations will become untracked. Maintenance will detect orphaned files as drift.`
: 'Scope describes eligibility for promoted files.'}
</p>
<p>
When automation is enabled, Maintenance rebuilds servers with drift
after verifying a safe server state. Rebuilds delete the existing
instance and all of its files.
</p>
</section>{/if}
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelScopeModal} disabled={updatingScope}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={updatingScope || scopePreviewLoading || !scopeImpact}
loading={updatingScope}
>
Save reviewed scope
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<!-- Upload dialog -->
<dialog
aria-label="Upload new version"
oncancel={(event) => {
if (uploadingVersion) event.preventDefault();
}}
bind:this={uploadDialog}
onclick={(e) => onDialogClick(uploadDialog, e)}
>
<form class="dlg" onsubmit={uploadVersion}>
<header class="dlg-head">
<h3>Upload new version</h3>
<button
type="button"
class="close"
disabled={uploadingVersion}
onclick={cancelUploadModal}
aria-label="Close">×</button
>
</header>
<Field label="File">
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
type="file"
accept={family === 'plugin' ? '.dll' : undefined}
onchange={pickVersionFile}
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button variant="ghost" onclick={cancelUploadModal} disabled={uploadingVersion}
>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>
{#if family === 'plugin'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
disabled={uploadingVersion}
/>
</Field>
{/if}
<Field label="Notes" hint="Optional. What changed since the previous version.">
<Input
bind:value={versionNotes}
placeholder="what changed"
disabled={uploadingVersion}
/>
</Field>
<div class="dlg-actions">
<Button
variant="ghost"
onclick={cancelUploadModal}
disabled={uploadingVersion}>Cancel</Button
>
<Button
type="submit"
variant="primary"
disabled={uploadingVersion || !versionFile}
loading={uploadingVersion}
>
Upload version
</Button>
</div>
{#if flash}<div class="alert alert-danger" role="alert">{flash}</div>{/if}
</form>
</dialog>{/if}
</div>
{/if}