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

View File

@@ -17,6 +17,7 @@
import ActionMenu from '$lib/ActionMenu.svelte'; import ActionMenu from '$lib/ActionMenu.svelte';
import RoleChip from '$lib/RoleChip.svelte'; import RoleChip from '$lib/RoleChip.svelte';
import { currentUser } from '$lib/auth'; import { currentUser } from '$lib/auth';
import { canAdminApp, canWriteApp } from '$lib/capabilities';
const me = $derived($currentUser); const me = $derived($currentUser);
@@ -36,7 +37,12 @@
let domains = $state<AppDomain[]>([]); let domains = $state<AppDomain[]>([]);
let members = $state<AppMemberDto[]>([]); 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 // Script create
let showCreateScript = $state(false); let showCreateScript = $state(false);
@@ -102,7 +108,7 @@
editDescription = app.description ?? ''; editDescription = app.description ?? '';
editSlug = app.slug; editSlug = app.slug;
const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)]; const loaders: Promise<unknown>[] = [loadScripts(app.id), loadDomains(app.id)];
if (canAdminMembers) { if (canAdmin) {
loaders.push(loadMembers(app.id), loadEligibleUsers()); loaders.push(loadMembers(app.id), loadEligibleUsers());
} }
await Promise.all(loaders); await Promise.all(loaders);
@@ -362,6 +368,16 @@
$effect(() => { $effect(() => {
void loadApp(); 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> </script>
{#if loading && !app} {#if loading && !app}
@@ -394,33 +410,35 @@
class:active={activeTab === 'domains'} class:active={activeTab === 'domains'}
onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button onclick={() => (activeTab = 'domains')}>Domains ({domains.length})</button
> >
{#if canAdminMembers} {#if canAdmin}
<button <button
type="button" type="button"
class:active={activeTab === 'members'} class:active={activeTab === 'members'}
onclick={() => (activeTab = 'members')}>Members ({members.length})</button onclick={() => (activeTab = 'members')}>Members ({members.length})</button
> >
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
{/if} {/if}
<button
type="button"
class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button
>
</nav> </nav>
{#if activeTab === 'scripts'} {#if activeTab === 'scripts'}
<section> <section>
<div class="row"> <div class="row">
<h2>Scripts</h2> <h2>Scripts</h2>
<button {#if canWrite}
type="button" <button
onclick={() => (showCreateScript = !showCreateScript)} type="button"
> onclick={() => (showCreateScript = !showCreateScript)}
{showCreateScript ? 'Cancel' : 'New script'} >
</button> {showCreateScript ? 'Cancel' : 'New script'}
</button>
{/if}
</div> </div>
{#if showCreateScript} {#if showCreateScript && canWrite}
<form class="create-form" onsubmit={submitCreateScript}> <form class="create-form" onsubmit={submitCreateScript}>
<div class="row"> <div class="row">
<label> <label>
@@ -473,18 +491,20 @@
these. Use <code>app.example.com</code> for exact, <code>*.example.com</code> for 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. wildcard, or <code>{'{'}tenant{'}'}.example.com</code> to bind a capture.
</p> </p>
<form class="create-form inline" onsubmit={submitCreateDomain}> {#if canAdmin}
<input <form class="create-form inline" onsubmit={submitCreateDomain}>
bind:value={createDomainPattern} <input
required bind:value={createDomainPattern}
placeholder="app.example.com" required
/> placeholder="app.example.com"
<button type="submit" disabled={creatingDomain}> />
{creatingDomain ? 'Adding…' : 'Add domain'} <button type="submit" disabled={creatingDomain}>
</button> {creatingDomain ? 'Adding…' : 'Add domain'}
</form> </button>
{#if createDomainError} </form>
<div class="error">{createDomainError}</div> {#if createDomainError}
<div class="error">{createDomainError}</div>
{/if}
{/if} {/if}
{#if domains.length === 0} {#if domains.length === 0}
<p class="muted">No domain claims yet.</p> <p class="muted">No domain claims yet.</p>
@@ -496,19 +516,21 @@
<code>{d.pattern}</code> <code>{d.pattern}</code>
<span class="muted">{d.shape}</span> <span class="muted">{d.shape}</span>
</div> </div>
<button {#if canAdmin}
type="button" <button
class="secondary danger" type="button"
onclick={() => askRemoveDomain(d)} class="secondary danger"
> onclick={() => askRemoveDomain(d)}
Delete >
</button> Delete
</button>
{/if}
</li> </li>
{/each} {/each}
</ul> </ul>
{/if} {/if}
</section> </section>
{:else if activeTab === 'members' && canAdminMembers} {:else if activeTab === 'members' && canAdmin}
<section> <section>
<h2>Members</h2> <h2>Members</h2>
<p class="muted"> <p class="muted">
@@ -623,7 +645,7 @@
</div> </div>
{/if} {/if}
</section> </section>
{:else if activeTab === 'settings'} {:else if activeTab === 'settings' && canAdmin}
<section> <section>
<h2>Settings</h2> <h2>Settings</h2>
<form class="create-form" onsubmit={(e) => saveSettings(e)}> <form class="create-form" onsubmit={(e) => saveSettings(e)}>