feat(dashboard): shadow script-detail surfaces by role

Captures my_role off the existing parent-app fetch (no extra HTTP call)
and uses canWriteApp / canAdminApp to hide: header Delete, Edit Save +
Format, Routing +Add route + per-row remove, and the Settings tab.
CodeEditor renders read-only for viewers. An effect bounces a stale
Settings tab back to Edit for non-admins.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 19:33:48 +02:00
parent d9c3d4d661
commit 75c815d02a

View File

@@ -6,12 +6,15 @@
api,
ApiError,
type AppDomain,
type AppRole,
type ExecutionLog,
type Route,
type RouteInput,
type Script,
type VersionInfo
} from '$lib/api';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
import { logLevelColor, statusColor } from '$lib/styles';
import {
checkHostAgainstClaims,
@@ -47,6 +50,11 @@
let appSlug = $state<string | null>(null);
let appDomains = $state<AppDomain[]>([]);
let appMyRole = $state<AppRole | null>(null);
const me = $derived($currentUser);
const canWrite = $derived(canWriteApp(me, appMyRole));
const canAdmin = $derived(canAdminApp(me, appMyRole));
async function loadScript() {
scriptLoading = true;
@@ -58,15 +66,16 @@
editableDescription = script.description ?? '';
editableTimeout = script.timeout_seconds;
editableSandbox = { ...(script.sandbox ?? {}) };
// Resolve the owning app's slug for the breadcrumb and its
// domain claims for the route form's suggestions + live
// validation. Both are non-fatal — the page works without
// them.
// Resolve the owning app for the breadcrumb (slug),
// route-form host suggestions (domain claims), and UI
// shadowing (my_role on this app). All non-fatal — the
// page renders without them, just with reduced fidelity.
const appId = script.app_id;
void api.apps
.get(appId)
.then((a) => {
appSlug = a.slug;
appMyRole = a.my_role ?? null;
})
.catch(() => {});
void api.domains
@@ -386,6 +395,15 @@
void loadRoutes();
void loadLogs();
});
// Defense-in-depth: anyone non-admin who lands on the Settings
// tab via a stale link gets bounced back to Edit. The tab button
// itself is also hidden.
$effect(() => {
if (!canAdmin && tab === 'settings') {
tab = 'edit';
}
});
</script>
<section>
@@ -410,9 +428,11 @@
v{script.version} · timeout {script.timeout_seconds}s · {script.description ?? 'no description'}
</p>
</div>
<button type="button" class="danger" onclick={remove} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
{#if canAdmin}
<button type="button" class="danger" onclick={remove} disabled={deleting}>
{deleting ? 'Deleting…' : 'Delete'}
</button>
{/if}
</header>
<nav class="tabs">
@@ -423,7 +443,9 @@
<span class="badge-count">{routes.length}</span>
{/if}
</button>
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
{#if canAdmin}
<button class:active={tab === 'settings'} onclick={() => (tab = 'settings')}>Settings</button>
{/if}
<button class:active={tab === 'executions'} onclick={() => (tab = 'executions')}>
Executions
</button>
@@ -435,26 +457,35 @@
<section class="card">
<header class="editor-header">
<h2>Source</h2>
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
{#if canWrite}
<button type="button" class="ghost small" onclick={formatRhaiSource}>
Format
</button>
{/if}
</header>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
<CodeEditor
bind:value={editableSource}
language="rhai"
minHeight="22rem"
readOnly={!canWrite}
/>
{#if rhaiFormatError}
<div class="error inline">{rhaiFormatError}</div>
{/if}
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
<div class="actions">
<button
type="button"
onclick={saveSource}
disabled={savingSource || editableSource === script.source}
>
{savingSource ? 'Saving…' : 'Save'}
</button>
</div>
{#if canWrite}
<div class="actions">
<button
type="button"
onclick={saveSource}
disabled={savingSource || editableSource === script.source}
>
{savingSource ? 'Saving…' : 'Save'}
</button>
</div>
{/if}
</section>
<section class="card">
@@ -510,12 +541,14 @@
<section class="card wide">
<header class="card-header">
<h2>Routes</h2>
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
{showAddRoute ? 'Cancel' : '+ Add route'}
</button>
{#if canWrite}
<button type="button" onclick={() => (showAddRoute = !showAddRoute)}>
{showAddRoute ? 'Cancel' : '+ Add route'}
</button>
{/if}
</header>
{#if showAddRoute}
{#if showAddRoute && canWrite}
<form class="route-form" onsubmit={submitRoute}>
<label class="full">
<span>Path</span>
@@ -626,9 +659,11 @@
: r.host}
</span>
<span class="path">{r.path}</span>
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
remove
</button>
{#if canWrite}
<button type="button" class="link danger" onclick={() => removeRoute(r.id)}>
remove
</button>
{/if}
</div>
{#if info}
<div class="route-url muted">{fullUrlForRoute(r)}</div>
@@ -670,7 +705,7 @@
</section>
<!-- ===================================================== SETTINGS ===== -->
{:else if tab === 'settings'}
{:else if tab === 'settings' && canAdmin}
<section class="card wide">
<h2>General</h2>
<label>