Remediate the dashboard-coverage and editor findings from the 2026-07-11 audit.
Close the group-surface gap: a GroupTabBar plus read-only surfaces for the v1.2
group data plane the dashboard previously exposed only via the CLI — vars,
secrets (names + timestamps only, never values), shared collections (KV/docs/
files browsers), scripts, triggers, routes, suppressions, extension points,
dead-letters, and ownership. All server data renders through Svelte's escaped
interpolation (no `{@html}`); each tab hits its existing
`/api/v1/admin/groups/{id}/...` read endpoint with independent loading/empty
state.
B7 — the CodeEditor `readOnly` prop is now reactive via a CodeMirror
Compartment, fixing the race where an authorized user opening a script on a warm
SPA nav got a permanently read-only editor (role resolved after mount). The
reconfigure preserves editor content.
A viewer lacking a read cap now sees a calm "you don't have permission" note on
every tab (structured LoadError + classifyError) instead of a red error panel,
generalizing the one-off Secrets 403 handling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89 lines
2.5 KiB
Svelte
89 lines
2.5 KiB
Svelte
<script lang="ts">
|
|
import { base } from '$app/paths';
|
|
|
|
interface Props {
|
|
slug: string;
|
|
/// Whether the current user can manage this group (instance owner/admin
|
|
/// today). Gates the management-only tabs (Members, Secrets, Settings).
|
|
canManage: boolean;
|
|
/// Active tab id — the `?tab=` query param the main group page reads.
|
|
currentTab: string;
|
|
}
|
|
|
|
let { slug, canManage, currentTab }: Props = $props();
|
|
|
|
interface Tab {
|
|
id: string;
|
|
label: string;
|
|
/// Management-only tabs (Members, Secrets, Settings) hide from a plain
|
|
/// viewer. The read-only inspection tabs stay visible to any viewer;
|
|
/// the backend still 403s the underlying calls if they can't read.
|
|
manageOnly: boolean;
|
|
}
|
|
|
|
// All tabs are in-page — the group page reads `?tab=` and renders the
|
|
// matching section, loading its data lazily on activation.
|
|
const tabs: Tab[] = [
|
|
{ id: 'overview', label: 'Overview', manageOnly: false },
|
|
{ id: 'members', label: 'Members', manageOnly: true },
|
|
{ id: 'vars', label: 'Vars', manageOnly: false },
|
|
{ id: 'secrets', label: 'Secrets', manageOnly: true },
|
|
{ id: 'collections', label: 'Collections', manageOnly: false },
|
|
{ id: 'scripts', label: 'Scripts', manageOnly: false },
|
|
{ id: 'triggers', label: 'Triggers', manageOnly: false },
|
|
{ id: 'routes', label: 'Routes', manageOnly: false },
|
|
{ id: 'suppressions', label: 'Suppressions', manageOnly: false },
|
|
{ id: 'extension-points', label: 'Extension points', manageOnly: false },
|
|
{ id: 'dead-letters', label: 'Dead-letters', manageOnly: false },
|
|
{ id: 'ownership', label: 'Ownership', manageOnly: false },
|
|
{ id: 'settings', label: 'Settings', manageOnly: true }
|
|
];
|
|
</script>
|
|
|
|
<nav class="tabbar" aria-label="Group sections">
|
|
{#each tabs as tab (tab.id)}
|
|
{#if !tab.manageOnly || canManage}
|
|
<a
|
|
href="{base}/groups/{slug}?tab={tab.id}"
|
|
class:active={tab.id === currentTab}
|
|
aria-current={tab.id === currentTab ? 'page' : undefined}
|
|
>
|
|
{tab.label}
|
|
</a>
|
|
{/if}
|
|
{/each}
|
|
</nav>
|
|
|
|
<style>
|
|
.tabbar {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 1.25rem;
|
|
border-bottom: 1px solid #1e293b;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.tabbar a {
|
|
color: #94a3b8;
|
|
text-decoration: none;
|
|
font-size: 0.9rem;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 2px solid transparent;
|
|
}
|
|
|
|
.tabbar a:hover {
|
|
color: #e2e8f0;
|
|
}
|
|
|
|
.tabbar a.active {
|
|
color: #e2e8f0;
|
|
border-bottom-color: #38bdf8;
|
|
}
|
|
|
|
.tabbar a:focus-visible {
|
|
outline: 2px solid #38bdf8;
|
|
outline-offset: -2px;
|
|
border-radius: 0.25rem;
|
|
}
|
|
</style>
|