feat(dashboard): group data-plane read surfaces + CodeEditor readOnly fix

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>
This commit is contained in:
MechaCat02
2026-07-11 23:48:09 +02:00
parent a3c4267f6c
commit c03c0c83c5
4 changed files with 1321 additions and 44 deletions

View File

@@ -13,7 +13,7 @@
import { onMount, onDestroy } from 'svelte';
import { basicSetup } from 'codemirror';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { EditorState, Compartment } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { json as jsonLang } from '@codemirror/lang-json';
import { rhai as rhaiLang } from './rhai-mode';
@@ -35,12 +35,23 @@
/** When true the editor renders without a cursor and rejects
* keystrokes. Parent-driven `value` changes still apply via
* the dispatch path below — this only blocks user edits.
* Not reactive after mount; re-mount via `{#key}` if needed. */
* Reactive after mount: held in a compartment reconfigured by the
* `$effect` below, so a role that resolves after mount (warm SPA
* nav) flips the editor writable without a remount. */
readOnly?: boolean;
} = $props();
let host: HTMLDivElement | null = null;
let view: EditorView | null = null;
// Holds the readOnly/editable pair so it can be reconfigured live when the
// `readOnly` prop changes (see the $effect below).
const readOnlyCompartment = new Compartment();
function readOnlyExtensions(ro: boolean) {
// readOnly blocks the underlying transactions; editable suppresses the
// caret + selection visuals so the user can see it's not editable.
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
}
// Guard against the update-listener firing while we're pushing an
// external value into the editor — without this, parent-driven
// `value` changes would echo back through the listener and create a
@@ -54,12 +65,8 @@
keymap.of([indentWithTab]),
dashboardSyntaxHighlighting,
dashboardTheme,
// readOnly + editable together: readOnly blocks the
// underlying transactions, editable suppresses the caret
// + selection visuals so the user can see it's not
// editable.
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
// Reconfigurable readOnly/editable — see the $effect below.
readOnlyCompartment.of(readOnlyExtensions(readOnly)),
EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString();
@@ -86,6 +93,17 @@
view = null;
});
// Keep the editor's read-only state in sync with the `readOnly` prop after
// mount. On a warm SPA navigation the parent's role/`canWrite` can resolve
// AFTER this component mounts; without this the editor would stay in its
// initial (often read-only) state even though Save is enabled.
$effect(() => {
if (!view) return;
view.dispatch({
effects: readOnlyCompartment.reconfigure(readOnlyExtensions(readOnly))
});
});
// Push parent-driven `value` updates back into the editor (e.g.
// when the script is reloaded after Save, or "Format JSON" rewrites
// the body). We only dispatch when the document genuinely differs

View File

@@ -0,0 +1,88 @@
<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>

View File

@@ -491,6 +491,105 @@ export interface SecretListItem {
updated_at: string;
}
// ---------------------------------------------------------------------------
// v1.2 group read-only inspection surfaces. Every shape below mirrors a Rust
// handler in crates/manager-core/src (vars_api, secrets_api, apply_api,
// group_blobs_api, group_scripts_api, projects_api). All READ-ONLY — group
// shared-collection writes + template authoring happen via the `pic` CLI.
// ---------------------------------------------------------------------------
/// One group var row (vars_api::VarItem). `env` is `*` (env-agnostic) or a
/// concrete env name; a tombstone suppresses an inherited key.
export interface GroupVarItem {
key: string;
env: string;
value: unknown;
is_tombstone: boolean;
updated_at: string;
}
/// One group secret row (secrets_api::SecretItem). NAMES + env + updated_at
/// only — values never leave the server.
export interface GroupSecretItem {
name: string;
env: string;
updated_at: string;
}
/// §11.6 shared-collection kinds. kv/docs/files have a data browser; topic and
/// queue are storeless / consumed differently (no per-group data endpoint).
export type GroupCollectionKind = 'kv' | 'docs' | 'files' | 'topic' | 'queue';
/// One declared shared collection (apply_service::CollectionInfo).
export interface GroupCollection {
name: string;
kind: GroupCollectionKind;
}
/// One group trigger-template row (apply_service::TriggerTemplateInfo).
export interface GroupTriggerTemplate {
kind: string;
target: string;
script: string;
enabled: boolean;
sealed: boolean;
shared: boolean;
}
/// One group route-template row (apply_service::RouteTemplateInfo).
export interface GroupRouteTemplate {
method: string;
host: string;
path_kind: string;
path: string;
script: string;
dispatch: string;
enabled: boolean;
sealed: boolean;
}
/// One group/app suppression row (apply_service::SuppressionInfo).
export interface SuppressionInfo {
target_kind: string;
reference: string;
}
/// One extension-point row (apply_service::ExtensionPointInfo).
export interface ExtensionPointInfo {
name: string;
declared_here: boolean;
provider: string | null;
}
/// One shared-collection doc entry (group_blobs_api::DocEntry).
export interface GroupDocEntry {
id: string;
data: unknown;
}
/// One group shared-queue dead-letter (group_blobs_api::DeadLetterDto). Note:
/// this shape is distinct from the per-app `DeadLetterRow` — it carries a
/// `collection` and no trigger/script fields.
export interface GroupDeadLetter {
id: string;
collection: string;
source: string;
op: string;
attempt_count: number;
last_error: string;
created_at: string;
resolved_at: string | null;
payload: unknown;
}
/// One project row (projects_api::ProjectListItem).
export interface ProjectListItem {
slug: string;
name: string;
owned_groups: number;
created_at: string;
}
export interface ExecutionResult {
status: number;
headers: Record<string, string>;
@@ -864,9 +963,122 @@ export const api = {
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
{ method: 'DELETE' }
)
},
// --- v1.2 read-only inspection surfaces (all GET) --------------
// The group's OWN vars (not the inherited/resolved view).
vars: (idOrSlug: string) =>
adminRequest<{ vars: GroupVarItem[] }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/vars`
),
// Secret NAMES + env + updated_at only — never values. Requires
// GroupSecretsWrite; a 403 is surfaced as a permission note, not a crash.
secrets: (idOrSlug: string) =>
adminRequest<{ secrets: GroupSecretItem[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/secrets`
),
// Declared shared collections (kv/docs/files/topic/queue).
collections: (idOrSlug: string) =>
adminRequest<GroupCollection[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/collections`
),
scripts: (idOrSlug: string) =>
adminRequest<Script[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/scripts`
),
triggers: (idOrSlug: string) =>
adminRequest<GroupTriggerTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/triggers`
),
routes: (idOrSlug: string) =>
adminRequest<GroupRouteTemplate[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/routes`
),
suppressions: (idOrSlug: string) =>
adminRequest<SuppressionInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/suppressions`
),
extensionPoints: (idOrSlug: string) =>
adminRequest<ExtensionPointInfo[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/extension-points`
),
// Shared-KV browser: list keys, then get a single value.
groupKv: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ keys: string[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, key: string) =>
adminRequest<{ value: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/kv/${encodeURIComponent(collection)}/${encodeURIComponent(key)}`
)
},
// Shared-docs browser: list documents (id + data), then get one.
groupDocs: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ docs: GroupDocEntry[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs?${params.toString()}`
);
},
get: (idOrSlug: string, collection: string, docId: string) =>
adminRequest<{ id: string; data: unknown }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/docs/${encodeURIComponent(collection)}/${docId}`
)
},
// Shared-files browser: list metadata + download URL (GET streams bytes).
groupFiles: {
list: (
idOrSlug: string,
collection: string,
opts: { cursor?: string; limit?: number } = {}
) => {
const params = new URLSearchParams();
params.set('collection', collection);
if (opts.cursor) params.set('cursor', opts.cursor);
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
return adminRequest<{ files: FileMeta[]; next_cursor: string | null }>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files?${params.toString()}`
);
},
downloadUrl: (idOrSlug: string, collection: string, fileId: string): string =>
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/files/${encodeURIComponent(collection)}/${fileId}`
},
// §11.6 D3 shared-queue dead-letters (newest-first).
deadLetters: (
idOrSlug: string,
opts: { unresolved?: boolean; limit?: number } = {}
) => {
const params = new URLSearchParams();
if (opts.unresolved) params.set('unresolved', 'true');
if (opts.limit !== undefined) params.set('limit', String(opts.limit));
const qs = params.toString();
return adminRequest<GroupDeadLetter[]>(
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/dead-letters${qs ? `?${qs}` : ''}`
);
}
},
projects: {
list: () => adminRequest<ProjectListItem[]>('/api/v1/admin/projects')
},
domains: {
listForApp: (idOrSlug: string) =>
adminRequest<AppDomain[]>(

File diff suppressed because it is too large Load Diff