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