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:
@@ -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
|
||||
|
||||
88
dashboard/src/lib/GroupTabBar.svelte
Normal file
88
dashboard/src/lib/GroupTabBar.svelte
Normal 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>
|
||||
@@ -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
Reference in New Issue
Block a user