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:
@@ -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)}>
|
||||
|
||||
Reference in New Issue
Block a user