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,6 +104,7 @@
<section> <section>
<header class="page-header"> <header class="page-header">
<h1>Apps</h1> <h1>Apps</h1>
{#if canCreate}
<button <button
type="button" type="button"
onclick={() => { onclick={() => {
@@ -108,9 +114,10 @@
> >
{showCreate ? 'Cancel' : 'New app'} {showCreate ? 'Cancel' : 'New app'}
</button> </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
> >
{/if}
<button <button
type="button" type="button"
class:active={activeTab === 'settings'} class:active={activeTab === 'settings'}
onclick={() => (activeTab = 'settings')}>Settings</button onclick={() => (activeTab = 'settings')}>Settings</button
> >
{/if}
</nav> </nav>
{#if activeTab === 'scripts'} {#if activeTab === 'scripts'}
<section> <section>
<div class="row"> <div class="row">
<h2>Scripts</h2> <h2>Scripts</h2>
{#if canWrite}
<button <button
type="button" type="button"
onclick={() => (showCreateScript = !showCreateScript)} onclick={() => (showCreateScript = !showCreateScript)}
> >
{showCreateScript ? 'Cancel' : 'New script'} {showCreateScript ? 'Cancel' : 'New script'}
</button> </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,6 +491,7 @@
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>
{#if canAdmin}
<form class="create-form inline" onsubmit={submitCreateDomain}> <form class="create-form inline" onsubmit={submitCreateDomain}>
<input <input
bind:value={createDomainPattern} bind:value={createDomainPattern}
@@ -486,6 +505,7 @@
{#if createDomainError} {#if createDomainError}
<div class="error">{createDomainError}</div> <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>
{:else} {:else}
@@ -496,6 +516,7 @@
<code>{d.pattern}</code> <code>{d.pattern}</code>
<span class="muted">{d.shape}</span> <span class="muted">{d.shape}</span>
</div> </div>
{#if canAdmin}
<button <button
type="button" type="button"
class="secondary danger" class="secondary danger"
@@ -503,12 +524,13 @@
> >
Delete Delete
</button> </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)}>