feat(dashboard): shadow apps + app-detail surfaces by role

Apps list: hide "New app" for members. App detail: hide New script for
viewers, Add domain + per-row Delete for non-admins, and the Members +
Settings tabs entirely for non-admins (with an effect that bounces a
stale activeTab back to Scripts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 19:31:56 +02:00
parent bef4d34c43
commit d9c3d4d661
2 changed files with 75 additions and 46 deletions

View File

@@ -2,6 +2,11 @@
import { base } from '$app/paths';
import { api, ApiError, type App } from '$lib/api';
import { slugify, SLUG_MAX } from '$lib/slugify';
import { canCreateApp } from '$lib/capabilities';
import { currentUser } from '$lib/auth';
const me = $derived($currentUser);
const canCreate = $derived(canCreateApp(me));
let apps = $state<App[] | null>(null);
let listError = $state<string | null>(null);
@@ -99,18 +104,20 @@
<section>
<header class="page-header">
<h1>Apps</h1>
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New app'}
</button>
{#if canCreate}
<button
type="button"
onclick={() => {
showCreate = !showCreate;
if (!showCreate) resetCreate();
}}
>
{showCreate ? 'Cancel' : 'New app'}
</button>
{/if}
</header>
{#if showCreate}
{#if showCreate && canCreate}
<form class="create-form" onsubmit={(e) => submitCreate(e)}>
<div class="row">
<label>

View File

@@ -17,6 +17,7 @@
import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser);
@@ -36,7 +37,12 @@
let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]);
const canAdminMembers = $derived(myRole === 'app_admin');
// Derive UI gates from the capabilities helper so the rules stay
// in lockstep with the backend's `can()`. canAdminApp also covers
// the Members + Settings + Domains-mutation tabs; canWriteApp
// covers New script.
const canWrite = $derived(canWriteApp(me, myRole));
const canAdmin = $derived(canAdminApp(me, myRole));
// Script create
let showCreateScript = $state(false);
@@ -102,7 +108,7 @@
editDescription = app.description ?? '';
editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) {
if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers());
}
await Promise.all(loaders);
@@ -362,6 +368,16 @@
$effect(() => {
void loadApp();
});
// Defense-in-depth: a viewer / editor following a stale link to
// the Settings or Members tab gets bounced back to Scripts. The
// backend still 403s the underlying calls, but no point showing an
// empty tab.
$effect(() => {
if (!canAdmin && (activeTab === 'settings' || activeTab === 'members')) {
activeTab = 'scripts';
}
});
</script>
{#if loading && !app}
@@ -394,33 +410,35 @@
class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
>
{#if canAdminMembers}
{#if canAdmin}
<button
type="button"
class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button
>
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if}
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
</nav>
{#if activeTab === 'scripts'}
<section>
<div class="row">
<h2>Scripts</h2>
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{#if canWrite}
<button
type="button"
onclick={() => (showCreateScript = !showCreateScript)}
>
{showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div>
{#if showCreateScript}
{#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}>
<div class="row">
<label>
@@ -473,18 +491,20 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for
wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p>
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}>
<input
bind:value={createDomainPattern}
required
placeholder="app.example.com"
/>
<button type="submit" disabled={creatingDomain}>
{creatingDomain ? 'Adding…' : 'Add domain'}
</button>
</form>
{#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if}
{#if domains.length === 0}
<p class="muted">No domain claims yet.</p>
@@ -496,19 +516,21 @@
<code>{d.pattern}</code>
<span class="muted">{d.shape}</span>
</div>
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
{#if canAdmin}
<button
type="button"
class="secondary danger"
onclick={() => askRemoveDomain(d)}
>
Delete
</button>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{:else if activeTab === 'members' && canAdminMembers}
{:else if activeTab === 'members' && canAdmin}
<section>
<h2>Members</h2>
<p class="muted">
@@ -623,7 +645,7 @@
</div>
{/if}
</section>
{:else if activeTab === 'settings'}
{:else if activeTab === 'settings' && canAdmin}
<section>
<h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}>