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 { onMount, onDestroy } from 'svelte';
|
||||||
import { basicSetup } from 'codemirror';
|
import { basicSetup } from 'codemirror';
|
||||||
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
|
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 { indentWithTab } from '@codemirror/commands';
|
||||||
import { json as jsonLang } from '@codemirror/lang-json';
|
import { json as jsonLang } from '@codemirror/lang-json';
|
||||||
import { rhai as rhaiLang } from './rhai-mode';
|
import { rhai as rhaiLang } from './rhai-mode';
|
||||||
@@ -35,12 +35,23 @@
|
|||||||
/** When true the editor renders without a cursor and rejects
|
/** When true the editor renders without a cursor and rejects
|
||||||
* keystrokes. Parent-driven `value` changes still apply via
|
* keystrokes. Parent-driven `value` changes still apply via
|
||||||
* the dispatch path below — this only blocks user edits.
|
* 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;
|
readOnly?: boolean;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let host: HTMLDivElement | null = null;
|
let host: HTMLDivElement | null = null;
|
||||||
let view: EditorView | 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
|
// Guard against the update-listener firing while we're pushing an
|
||||||
// external value into the editor — without this, parent-driven
|
// external value into the editor — without this, parent-driven
|
||||||
// `value` changes would echo back through the listener and create a
|
// `value` changes would echo back through the listener and create a
|
||||||
@@ -54,12 +65,8 @@
|
|||||||
keymap.of([indentWithTab]),
|
keymap.of([indentWithTab]),
|
||||||
dashboardSyntaxHighlighting,
|
dashboardSyntaxHighlighting,
|
||||||
dashboardTheme,
|
dashboardTheme,
|
||||||
// readOnly + editable together: readOnly blocks the
|
// Reconfigurable readOnly/editable — see the $effect below.
|
||||||
// underlying transactions, editable suppresses the caret
|
readOnlyCompartment.of(readOnlyExtensions(readOnly)),
|
||||||
// + selection visuals so the user can see it's not
|
|
||||||
// editable.
|
|
||||||
EditorState.readOnly.of(readOnly),
|
|
||||||
EditorView.editable.of(!readOnly),
|
|
||||||
EditorView.updateListener.of((update) => {
|
EditorView.updateListener.of((update) => {
|
||||||
if (update.docChanged && !pushingFromOutside) {
|
if (update.docChanged && !pushingFromOutside) {
|
||||||
value = update.state.doc.toString();
|
value = update.state.doc.toString();
|
||||||
@@ -86,6 +93,17 @@
|
|||||||
view = null;
|
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.
|
// Push parent-driven `value` updates back into the editor (e.g.
|
||||||
// when the script is reloaded after Save, or "Format JSON" rewrites
|
// when the script is reloaded after Save, or "Format JSON" rewrites
|
||||||
// the body). We only dispatch when the document genuinely differs
|
// 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;
|
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 {
|
export interface ExecutionResult {
|
||||||
status: number;
|
status: number;
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
@@ -864,9 +963,122 @@ export const api = {
|
|||||||
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
`/api/v1/admin/groups/${encodeURIComponent(idOrSlug)}/members/${userId}`,
|
||||||
{ method: 'DELETE' }
|
{ 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: {
|
domains: {
|
||||||
listForApp: (idOrSlug: string) =>
|
listForApp: (idOrSlug: string) =>
|
||||||
adminRequest<AppDomain[]>(
|
adminRequest<AppDomain[]>(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user