diff --git a/packages/ui/src/lib/api.ts b/packages/ui/src/lib/api.ts index dcf294c5..512ab1b3 100644 --- a/packages/ui/src/lib/api.ts +++ b/packages/ui/src/lib/api.ts @@ -9,19 +9,25 @@ export { loadConfig, loadObserverStatus, saveConfig } from "./api/config"; export { archiveCoordinatorAdminGroup, createCoordinatorAdminGroup, + createCoordinatorAdminScope, createCoordinatorInvite, disableCoordinatorAdminDevice, enableCoordinatorAdminDevice, + grantCoordinatorAdminScopeMember, loadCoordinatorAdminDevices, loadCoordinatorAdminGroups, loadCoordinatorAdminGroupsFiltered, loadCoordinatorAdminJoinRequests, + loadCoordinatorAdminScopeMembers, + loadCoordinatorAdminScopes, loadCoordinatorAdminStatus, removeCoordinatorAdminDevice, renameCoordinatorAdminDevice, renameCoordinatorAdminGroup, reviewCoordinatorAdminJoinRequest, + revokeCoordinatorAdminScopeMember, unarchiveCoordinatorAdminGroup, + updateCoordinatorAdminScope, } from "./api/coordinator-admin"; export { forgetMemory, diff --git a/packages/ui/src/lib/api/coordinator-admin.ts b/packages/ui/src/lib/api/coordinator-admin.ts index faaf43c7..8fc3dc4d 100644 --- a/packages/ui/src/lib/api/coordinator-admin.ts +++ b/packages/ui/src/lib/api/coordinator-admin.ts @@ -76,6 +76,7 @@ export interface CoordinatorAdminScopePayload { kind?: string | null; authority_type?: string | null; coordinator_id?: string | null; + group_id?: string | null; manifest_issuer_device_id?: string | null; membership_epoch?: number | null; manifest_hash?: string | null; @@ -83,10 +84,13 @@ export interface CoordinatorAdminScopePayload { } export interface CoordinatorAdminScopeMemberPayload { + scope_id?: string; device_id?: string; role?: string | null; + status?: string | null; membership_epoch?: number | null; coordinator_id?: string | null; + group_id?: string | null; manifest_issuer_device_id?: string | null; manifest_hash?: string | null; signed_manifest_json?: string | null; diff --git a/packages/ui/src/tabs/coordinator-admin/components/groups-panel.ts b/packages/ui/src/tabs/coordinator-admin/components/groups-panel.ts index 555a50c7..f8c58375 100644 --- a/packages/ui/src/tabs/coordinator-admin/components/groups-panel.ts +++ b/packages/ui/src/tabs/coordinator-admin/components/groups-panel.ts @@ -21,6 +21,11 @@ import { currentAdminTargetGroup, setAdminTargetGroup, } from "../data/target-group"; +import { + closeGroupScopeManagement, + openGroupScopeManagement, + renderGroupScopeManagementPanel, +} from "./scope-management-panel"; /* Inlined chevron SVG — matches the sync-tab device-row chevron so the * CSS `[data-state="open"]` rotation style is shared. Avoids depending @@ -110,7 +115,7 @@ async function saveGroupPreferences(groupId: string, renderShell: () => void): P try { await api.saveCoordinatorGroupPreferences(groupId, payload); showGlobalNotice( - "Group scope defaults saved. New peers enrolled through this team will use these defaults.", + "Group project defaults saved. New peers enrolled through this team will use these filters.", ); closeGroupPreferences(groupId, renderShell); } catch (error) { @@ -135,7 +140,7 @@ function renderGroupPreferencesEditor( const draft = coordinatorAdminState.groupPreferencesDrafts.get(groupId); if (!draft) return null; if (!draft.loaded) { - return h("div", { class: "peer-submeta" }, "Loading scope defaults…"); + return h("div", { class: "peer-submeta" }, "Loading project defaults…"); } const autoSeedLabelId = `coord-admin-scope-autoseed-${groupId}`; const includeLabelId = `coord-admin-scope-include-${groupId}`; @@ -144,11 +149,11 @@ function renderGroupPreferencesEditor( return h( Fragment, null, - h("h4", { class: "coordinator-admin-drawer-title" }, "Scope defaults"), + h("h4", { class: "coordinator-admin-drawer-title" }, "Project defaults"), h( "div", { class: "peer-submeta" }, - "When enabled, new peers enrolled through this team start with the project filters below. Existing peers are unchanged.", + "When enabled, new peers enrolled through this coordinator group start with the project filters below. Existing peers are unchanged, and these filters never grant sharing-domain access.", ), // Master toggle first — the include/exclude chips are only meaningful // when auto-seed is on, so the form reads top-down from decision @@ -156,7 +161,7 @@ function renderGroupPreferencesEditor( h( "label", { class: "coordinator-admin-inline-filter" }, - h("span", { class: "section-meta", id: autoSeedLabelId }, "Auto-seed scope on new peers"), + h("span", { class: "section-meta", id: autoSeedLabelId }, "Auto-seed project filters"), h(RadixSwitch, { "aria-labelledby": autoSeedLabelId, checked: draft.auto_seed_scope, @@ -402,6 +407,7 @@ export function renderGroupsPanel(deps: GroupsPanelDeps) { group.display_name ?? group.group_id; const scopeOpen = coordinatorAdminState.groupPreferencesOpen.has(group.group_id); + const domainsOpen = coordinatorAdminState.groupScopeManagementOpen.has(group.group_id); return h( "div", { class: "peer-card peer-card--padded", key: group.group_id }, @@ -464,7 +470,7 @@ export function renderGroupsPanel(deps: GroupsPanelDeps) { "button", { "aria-expanded": scopeOpen, - "aria-controls": `coord-admin-scope-drawer-${group.group_id}`, + "aria-controls": `coord-admin-project-defaults-drawer-${group.group_id}`, class: "settings-button coordinator-admin-scope-trigger", "data-state": scopeOpen ? "open" : "closed", disabled: summary.readiness !== "ready" || pending, @@ -477,7 +483,31 @@ export function renderGroupsPanel(deps: GroupsPanelDeps) { }, type: "button", }, - h("span", null, "Scope defaults"), + h("span", null, "Project defaults"), + h( + "span", + { "aria-hidden": "true", class: "device-row-chevron" }, + h(ChevronRightIcon, null), + ), + ), + h( + "button", + { + "aria-expanded": domainsOpen, + "aria-controls": `coord-admin-domains-drawer-${group.group_id}`, + class: "settings-button coordinator-admin-scope-trigger", + "data-state": domainsOpen ? "open" : "closed", + disabled: summary.readiness !== "ready" || pending, + onClick: () => { + if (domainsOpen) { + closeGroupScopeManagement(group.group_id, renderShell); + } else { + openGroupScopeManagement(group.group_id, renderShell); + } + }, + type: "button", + }, + h("span", null, "Sharing domains"), h( "span", { "aria-hidden": "true", class: "device-row-chevron" }, @@ -520,9 +550,9 @@ export function renderGroupsPanel(deps: GroupsPanelDeps) { h( Collapsible.Content, { - "aria-label": `Scope defaults for ${draftName}`, + "aria-label": `Project defaults for ${draftName}`, class: "coordinator-admin-group-preferences", - id: `coord-admin-scope-drawer-${group.group_id}`, + id: `coord-admin-project-defaults-drawer-${group.group_id}`, }, scopeOpen ? renderGroupPreferencesEditor( @@ -533,6 +563,33 @@ export function renderGroupsPanel(deps: GroupsPanelDeps) { : null, ), ), + h( + Collapsible.Root, + { + open: domainsOpen, + onOpenChange: (open: boolean) => { + if (open) openGroupScopeManagement(group.group_id, renderShell); + else closeGroupScopeManagement(group.group_id, renderShell); + }, + }, + h( + Collapsible.Content, + { + "aria-label": `Sharing domains for ${draftName}`, + class: + "coordinator-admin-group-preferences coordinator-admin-domain-management", + id: `coord-admin-domains-drawer-${group.group_id}`, + }, + domainsOpen + ? renderGroupScopeManagementPanel({ + groupId: group.group_id, + ready: summary.readiness === "ready", + renderShell, + summary, + }) + : null, + ), + ), ); }), ), diff --git a/packages/ui/src/tabs/coordinator-admin/components/scope-management-panel.ts b/packages/ui/src/tabs/coordinator-admin/components/scope-management-panel.ts new file mode 100644 index 00000000..2c3a7c98 --- /dev/null +++ b/packages/ui/src/tabs/coordinator-admin/components/scope-management-panel.ts @@ -0,0 +1,476 @@ +import { Fragment, h } from "preact"; +import { RadixSwitch } from "../../../components/primitives/radix-switch"; +import { TextInput } from "../../../components/primitives/text-input"; +import * as api from "../../../lib/api"; +import { showGlobalNotice } from "../../../lib/notice"; +import type { CachedCoordinatorAdminDevice } from "../../../lib/state"; +import { openSyncConfirmDialog } from "../../sync/sync-dialogs"; +import { + type CoordinatorAdminScopeMemberView, + type CoordinatorAdminScopeView, + deriveScopeMembershipDeviceRows, + scopeManagementReadinessMessage, + scopeStatusLabel, +} from "../data/scope-management"; +import { coordinatorAdminState, type GroupScopeManagementDraft } from "../data/state"; +import type { CoordinatorAdminSummary } from "../data/summary"; + +interface ScopeManagementPanelDeps { + groupId: string; + ready: boolean; + summary: CoordinatorAdminSummary; + renderShell: () => void; +} + +function emptyScopeDraft(): GroupScopeManagementDraft { + return { + loaded: false, + loading: false, + error: "", + includeInactive: false, + scopes: [], + membersByScope: new Map(), + devices: [], + createScopeId: "", + createLabel: "", + createKind: "team", + actionPendingKey: "", + actionPendingKind: "", + }; +} + +function payloadItems(payload: unknown): T[] { + if (!payload || typeof payload !== "object") return []; + const items = (payload as { items?: unknown }).items; + return Array.isArray(items) ? (items as T[]) : []; +} + +function draftFor(groupId: string): GroupScopeManagementDraft { + let draft = coordinatorAdminState.groupScopeManagementDrafts.get(groupId); + if (!draft) { + draft = emptyScopeDraft(); + coordinatorAdminState.groupScopeManagementDrafts.set(groupId, draft); + } + return draft; +} + +function setDraft(groupId: string, draft: GroupScopeManagementDraft): void { + coordinatorAdminState.groupScopeManagementDrafts.set(groupId, draft); +} + +async function loadGroupScopeManagement( + groupId: string, + renderShell: () => void, + includeInactive = draftFor(groupId).includeInactive, +): Promise { + const current = draftFor(groupId); + setDraft(groupId, { ...current, loading: true, error: "", includeInactive }); + renderShell(); + try { + const [scopesPayload, devicesPayload] = await Promise.all([ + api.loadCoordinatorAdminScopes(groupId, includeInactive), + api.loadCoordinatorAdminDevices(groupId, true), + ]); + const scopes = payloadItems(scopesPayload); + const devices = payloadItems(devicesPayload); + const memberEntries = await Promise.all( + scopes.map(async (scope) => { + const scopeId = String(scope.scope_id || "").trim(); + if (!scopeId) return [scopeId, [] as CoordinatorAdminScopeMemberView[]] as const; + const payload = await api.loadCoordinatorAdminScopeMembers(groupId, scopeId, true); + return [scopeId, payloadItems(payload)] as const; + }), + ); + setDraft(groupId, { + ...draftFor(groupId), + loaded: true, + loading: false, + error: "", + includeInactive, + scopes, + devices, + membersByScope: new Map(memberEntries.filter(([scopeId]) => scopeId.length > 0)), + actionPendingKey: "", + actionPendingKind: "", + }); + } catch (error) { + setDraft(groupId, { + ...draftFor(groupId), + loaded: true, + loading: false, + error: error instanceof Error ? error.message : "Failed to load sharing domains.", + }); + } + renderShell(); +} + +export function openGroupScopeManagement(groupId: string, renderShell: () => void): void { + coordinatorAdminState.groupScopeManagementOpen.add(groupId); + draftFor(groupId); + renderShell(); + void loadGroupScopeManagement(groupId, renderShell); +} + +export function closeGroupScopeManagement(groupId: string, renderShell: () => void): void { + coordinatorAdminState.groupScopeManagementOpen.delete(groupId); + coordinatorAdminState.groupScopeManagementDrafts.delete(groupId); + renderShell(); +} + +async function createScope(groupId: string, renderShell: () => void): Promise { + const draft = draftFor(groupId); + if (draft.actionPendingKey) return; + const scopeId = draft.createScopeId.trim(); + const label = draft.createLabel.trim(); + const kind = draft.createKind.trim() || "team"; + if (!scopeId || !label) { + showGlobalNotice("Enter a scope id and label before creating a sharing domain.", "warning"); + return; + } + setDraft(groupId, { + ...draft, + actionPendingKey: `create:${scopeId}`, + actionPendingKind: "create", + error: "", + }); + renderShell(); + try { + await api.createCoordinatorAdminScope(groupId, { + scope_id: scopeId, + label, + kind, + }); + const latest = draftFor(groupId); + setDraft(groupId, { + ...latest, + createScopeId: "", + createLabel: "", + createKind: "team", + actionPendingKey: "", + actionPendingKind: "", + }); + showGlobalNotice("Sharing domain created. Grant devices explicitly before data can sync."); + await loadGroupScopeManagement(groupId, renderShell, latest.includeInactive); + } catch (error) { + setDraft(groupId, { + ...draftFor(groupId), + actionPendingKey: "", + actionPendingKind: "", + error: error instanceof Error ? error.message : "Failed to create sharing domain.", + }); + renderShell(); + } +} + +async function grantMember( + groupId: string, + scopeId: string, + deviceId: string, + renderShell: () => void, +): Promise { + const draft = draftFor(groupId); + const key = `grant:${scopeId}:${deviceId}`; + if (draft.actionPendingKey) return; + setDraft(groupId, { ...draft, actionPendingKey: key, actionPendingKind: "grant", error: "" }); + renderShell(); + try { + await api.grantCoordinatorAdminScopeMember(groupId, scopeId, { + device_id: deviceId, + role: "member", + }); + showGlobalNotice("Device granted access to the sharing domain."); + await loadGroupScopeManagement(groupId, renderShell, draft.includeInactive); + } catch (error) { + setDraft(groupId, { + ...draftFor(groupId), + actionPendingKey: "", + actionPendingKind: "", + error: error instanceof Error ? error.message : "Failed to grant scope membership.", + }); + renderShell(); + } +} + +async function revokeMember( + groupId: string, + scope: CoordinatorAdminScopeView, + deviceId: string, + displayName: string, + renderShell: () => void, +): Promise { + const scopeId = String(scope.scope_id || "").trim(); + if (!scopeId) return; + const confirmed = await openSyncConfirmDialog({ + title: `Revoke ${displayName || deviceId} from ${scope.label || scopeId}?`, + description: + "Revocation only blocks future sync for this sharing domain. Data already copied to that device can remain there.", + confirmLabel: "Revoke membership", + cancelLabel: "Keep membership", + tone: "danger", + }); + if (!confirmed) return; + const draft = draftFor(groupId); + const key = `revoke:${scopeId}:${deviceId}`; + if (draft.actionPendingKey) return; + setDraft(groupId, { ...draft, actionPendingKey: key, actionPendingKind: "revoke", error: "" }); + renderShell(); + try { + await api.revokeCoordinatorAdminScopeMember(groupId, scopeId, deviceId); + showGlobalNotice("Scope membership revoked. Future sync is blocked for that device."); + await loadGroupScopeManagement(groupId, renderShell, draft.includeInactive); + } catch (error) { + setDraft(groupId, { + ...draftFor(groupId), + actionPendingKey: "", + actionPendingKind: "", + error: error instanceof Error ? error.message : "Failed to revoke scope membership.", + }); + renderShell(); + } +} + +function renderMembershipRows( + groupId: string, + scope: CoordinatorAdminScopeView, + draft: GroupScopeManagementDraft, + ready: boolean, + renderShell: () => void, +) { + const scopeId = String(scope.scope_id || "").trim(); + const rows = deriveScopeMembershipDeviceRows( + draft.devices, + draft.membersByScope.get(scopeId) ?? [], + ); + if (!rows.length) { + return h( + "div", + { class: "peer-submeta coordinator-admin-empty-state" }, + "No enrolled devices in this group yet. Enroll a device before granting this sharing domain.", + ); + } + return h( + "div", + { class: "coordinator-admin-scope-member-list" }, + rows.map((row) => { + const pendingKey = `${draft.actionPendingKind}:${scopeId}:${row.deviceId}`; + const pending = draft.actionPendingKey === pendingKey; + const canGrant = row.enabled && row.status !== "active"; + const canRevoke = row.status === "active"; + const statusCopy = + row.status === "not_member" + ? "Not a member" + : row.status === "revoked" + ? "Revoked" + : "Active member"; + const epochCopy = row.membershipEpoch == null ? "epoch —" : `epoch ${row.membershipEpoch}`; + return h( + "div", + { class: "coordinator-admin-scope-member-row", key: row.deviceId }, + h( + "div", + { class: "coordinator-admin-scope-member-copy" }, + h("strong", null, row.displayName), + h("span", null, `${statusCopy} · ${row.role} · ${epochCopy}`), + row.enabled ? null : h("span", null, "Device is disabled in this coordinator group."), + ), + h( + "div", + { class: "peer-actions" }, + canGrant + ? h( + "button", + { + class: "settings-button", + disabled: !ready || Boolean(draft.actionPendingKey), + onClick: () => void grantMember(groupId, scopeId, row.deviceId, renderShell), + type: "button", + }, + pending && draft.actionPendingKind === "grant" ? "Granting…" : "Grant", + ) + : null, + canRevoke + ? h( + "button", + { + class: "settings-button danger", + disabled: !ready || Boolean(draft.actionPendingKey), + onClick: () => + void revokeMember(groupId, scope, row.deviceId, row.displayName, renderShell), + type: "button", + }, + pending && draft.actionPendingKind === "revoke" ? "Revoking…" : "Revoke", + ) + : null, + ), + ); + }), + ); +} + +function renderScopeCard( + groupId: string, + scope: CoordinatorAdminScopeView, + draft: GroupScopeManagementDraft, + ready: boolean, + renderShell: () => void, +) { + const scopeId = String(scope.scope_id || "").trim(); + const label = String(scope.label || scopeId || "Untitled sharing domain"); + return h( + "div", + { class: "peer-card peer-card--padded coordinator-admin-scope-card", key: scopeId || label }, + h("div", { class: "peer-title" }, h("strong", null, label)), + h("div", { class: "peer-meta" }, `Scope ID: ${scopeId || "unknown"}`), + h( + "div", + { class: "peer-submeta" }, + `Kind: ${scope.kind || "user"} · Status: ${scopeStatusLabel(scope.status)} · Membership epoch: ${scope.membership_epoch ?? 0}`, + ), + h( + "div", + { class: "peer-submeta" }, + "Devices below are enrolled in the coordinator group; only active members can sync this sharing domain.", + ), + renderMembershipRows(groupId, scope, draft, ready, renderShell), + ); +} + +export function renderGroupScopeManagementPanel(deps: ScopeManagementPanelDeps) { + const { groupId, ready, summary, renderShell } = deps; + const draft = coordinatorAdminState.groupScopeManagementDrafts.get(groupId); + if (!draft) return null; + const readinessMessage = scopeManagementReadinessMessage(summary); + if (readinessMessage) { + return h("div", { class: "peer-meta coordinator-admin-inline-warning" }, readinessMessage); + } + if (!draft.loaded) { + return h("div", { class: "peer-submeta" }, "Loading sharing domains…"); + } + const disabled = !ready || Boolean(draft.actionPendingKey) || draft.loading; + return h( + Fragment, + null, + h("h4", { class: "coordinator-admin-drawer-title" }, "Sharing domains"), + h( + "div", + { class: "peer-submeta" }, + "Coordinator groups discover and enroll devices. Sharing domains grant data access. Granting a device here is explicit; group membership alone does not share memories.", + ), + h( + "label", + { class: "coordinator-admin-inline-filter" }, + h( + "span", + { class: "section-meta", id: `coord-admin-domain-inactive-${groupId}` }, + "Show inactive domains", + ), + h(RadixSwitch, { + "aria-labelledby": `coord-admin-domain-inactive-${groupId}`, + checked: draft.includeInactive, + className: "coordinator-admin-switch", + disabled, + onCheckedChange: (checked: boolean) => { + void loadGroupScopeManagement(groupId, renderShell, checked); + }, + thumbClassName: "coordinator-admin-switch-thumb", + }), + ), + h( + "div", + { class: "coordinator-admin-form-grid" }, + h( + "label", + { class: "coordinator-admin-field" }, + h("span", null, "New domain id"), + h(TextInput, { + class: "peer-scope-input", + disabled, + onInput: (event) => { + const current = draftFor(groupId); + setDraft(groupId, { + ...current, + createScopeId: String((event.currentTarget as HTMLInputElement).value || ""), + }); + }, + placeholder: "acme-work", + type: "text", + value: draft.createScopeId, + }), + ), + h( + "label", + { class: "coordinator-admin-field" }, + h("span", null, "Label"), + h(TextInput, { + class: "peer-scope-input", + disabled, + onInput: (event) => { + const current = draftFor(groupId); + setDraft(groupId, { + ...current, + createLabel: String((event.currentTarget as HTMLInputElement).value || ""), + }); + }, + placeholder: "Acme Work", + type: "text", + value: draft.createLabel, + }), + ), + h( + "label", + { class: "coordinator-admin-field" }, + h("span", null, "Kind"), + h(TextInput, { + class: "peer-scope-input", + disabled, + onInput: (event) => { + const current = draftFor(groupId); + setDraft(groupId, { + ...current, + createKind: String((event.currentTarget as HTMLInputElement).value || ""), + }); + }, + placeholder: "team", + type: "text", + value: draft.createKind, + }), + ), + ), + h( + "div", + { class: "peer-actions" }, + h( + "button", + { + class: "settings-button", + disabled, + onClick: () => void createScope(groupId, renderShell), + type: "button", + }, + draft.actionPendingKind === "create" ? "Creating…" : "Create sharing domain", + ), + h( + "button", + { + class: "settings-button", + disabled, + onClick: () => void loadGroupScopeManagement(groupId, renderShell, draft.includeInactive), + type: "button", + }, + draft.loading ? "Refreshing…" : "Refresh", + ), + ), + draft.error ? h("div", { class: "peer-submeta coordinator-admin-error" }, draft.error) : null, + draft.scopes.length + ? h( + "div", + { class: "coordinator-admin-scope-card-list" }, + draft.scopes.map((scope) => renderScopeCard(groupId, scope, draft, ready, renderShell)), + ) + : h( + "div", + { class: "peer-meta coordinator-admin-empty-state" }, + "No sharing domains are defined for this group yet. Create one, then grant specific devices.", + ), + ); +} diff --git a/packages/ui/src/tabs/coordinator-admin/data/scope-management.test.ts b/packages/ui/src/tabs/coordinator-admin/data/scope-management.test.ts new file mode 100644 index 00000000..b71c2495 --- /dev/null +++ b/packages/ui/src/tabs/coordinator-admin/data/scope-management.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + deriveScopeMembershipDeviceRows, + scopeManagementReadinessMessage, + scopeStatusLabel, +} from "./scope-management"; + +describe("coordinator admin scope management view helpers", () => { + it("gates sharing-domain management when admin setup is incomplete", () => { + expect( + scopeManagementReadinessMessage({ + readiness: "partial", + title: "Coordinator admin setup is incomplete", + detail: "Set a coordinator admin secret.", + }), + ).toContain("admin secret"); + expect( + scopeManagementReadinessMessage({ + readiness: "ready", + title: "Ready", + detail: "Ready", + }), + ).toBeNull(); + }); + + it("shows enrolled devices that are not members as explicit non-member rows", () => { + const rows = deriveScopeMembershipDeviceRows( + [ + { device_id: "dev-a", display_name: "Alice laptop", enabled: true }, + { device_id: "dev-b", display_name: "Build box", enabled: true }, + { device_id: "dev-c", display_name: "Old phone", enabled: false }, + ], + [ + { + device_id: "dev-a", + role: "admin", + status: "active", + membership_epoch: 4, + updated_at: "2026-05-05T00:00:00Z", + }, + { device_id: "dev-c", role: "member", status: "revoked", membership_epoch: 6 }, + ], + ); + + expect(rows.map((row) => [row.deviceId, row.status, row.role, row.membershipEpoch])).toEqual([ + ["dev-a", "active", "admin", 4], + ["dev-c", "revoked", "member", 6], + ["dev-b", "not_member", "member", null], + ]); + expect(rows[2]?.displayName).toBe("Build box"); + }); + + it("formats empty or underscored scope statuses for operator copy", () => { + expect(scopeStatusLabel(null)).toBe("active"); + expect(scopeStatusLabel("needs_review")).toBe("needs review"); + }); +}); diff --git a/packages/ui/src/tabs/coordinator-admin/data/scope-management.ts b/packages/ui/src/tabs/coordinator-admin/data/scope-management.ts new file mode 100644 index 00000000..93309399 --- /dev/null +++ b/packages/ui/src/tabs/coordinator-admin/data/scope-management.ts @@ -0,0 +1,88 @@ +import type { CachedCoordinatorAdminDevice } from "../../../lib/state"; +import type { CoordinatorAdminSummary } from "./summary"; + +export interface CoordinatorAdminScopeView { + scope_id?: string; + label?: string | null; + kind?: string | null; + authority_type?: string | null; + membership_epoch?: number | null; + status?: string | null; +} + +export interface CoordinatorAdminScopeMemberView { + device_id?: string; + role?: string | null; + status?: string | null; + membership_epoch?: number | null; + updated_at?: string | null; +} + +export type ScopeMembershipStatus = "active" | "revoked" | "not_member"; + +export interface ScopeMembershipDeviceRow { + deviceId: string; + displayName: string; + enabled: boolean; + status: ScopeMembershipStatus; + role: string; + membershipEpoch: number | null; + updatedAt: string | null; +} + +export function scopeManagementReadinessMessage(summary: CoordinatorAdminSummary): string | null { + if (summary.readiness === "ready") return null; + return "Sharing domain management needs the coordinator URL, target group, and admin secret before it can list scopes or change memberships."; +} + +export function scopeStatusLabel(status: string | null | undefined): string { + const value = String(status || "active").trim(); + return value ? value.replaceAll("_", " ") : "active"; +} + +export function deriveScopeMembershipDeviceRows( + devices: CachedCoordinatorAdminDevice[], + members: CoordinatorAdminScopeMemberView[], +): ScopeMembershipDeviceRow[] { + const memberByDevice = new Map( + members + .map((member) => [String(member.device_id || "").trim(), member] as const) + .filter(([deviceId]) => deviceId.length > 0), + ); + return devices + .map((device) => { + const deviceId = String(device.device_id || "").trim(); + if (!deviceId) return null; + const member = memberByDevice.get(deviceId); + const rawStatus = String(member?.status || "").trim(); + const status: ScopeMembershipStatus = member + ? rawStatus === "revoked" + ? "revoked" + : "active" + : "not_member"; + const membershipEpoch = + typeof member?.membership_epoch === "number" && Number.isFinite(member.membership_epoch) + ? Math.trunc(member.membership_epoch) + : null; + return { + deviceId, + displayName: String(device.display_name || deviceId || "Unnamed device"), + enabled: device.enabled !== false && device.enabled !== 0, + status, + role: String(member?.role || "member"), + membershipEpoch, + updatedAt: member?.updated_at ? String(member.updated_at) : null, + }; + }) + .filter((row): row is ScopeMembershipDeviceRow => row !== null) + .sort((a, b) => { + const statusRank: Record = { + active: 0, + revoked: 1, + not_member: 2, + }; + return ( + statusRank[a.status] - statusRank[b.status] || a.displayName.localeCompare(b.displayName) + ); + }); +} diff --git a/packages/ui/src/tabs/coordinator-admin/data/state.ts b/packages/ui/src/tabs/coordinator-admin/data/state.ts index bb49251d..e4a2faaf 100644 --- a/packages/ui/src/tabs/coordinator-admin/data/state.ts +++ b/packages/ui/src/tabs/coordinator-admin/data/state.ts @@ -3,12 +3,19 @@ * exported object so the panels/actions/lifecycle slices can all read * and write it without hitting ES-module `export let` limitations. */ +import type { CachedCoordinatorAdminDevice } from "../../../lib/state"; +import type { + CoordinatorAdminScopeMemberView, + CoordinatorAdminScopeView, +} from "./scope-management"; + export type AdminSection = "groups" | "invites" | "join-requests" | "devices"; export type GroupActionKind = "create" | "rename" | "archive" | "unarchive" | ""; export type JoinReviewAction = "approve" | "deny" | ""; export type DeviceActionKind = "rename" | "disable" | "enable" | "remove" | ""; export type InvitePolicy = "auto_admit" | "approval_required"; +export type ScopeManagementActionKind = "load" | "create" | "grant" | "revoke" | ""; export interface GroupPreferencesDraft { projects_include: string[]; @@ -19,6 +26,21 @@ export interface GroupPreferencesDraft { error: string; } +export interface GroupScopeManagementDraft { + loaded: boolean; + loading: boolean; + error: string; + includeInactive: boolean; + scopes: CoordinatorAdminScopeView[]; + membersByScope: Map; + devices: CachedCoordinatorAdminDevice[]; + createScopeId: string; + createLabel: string; + createKind: string; + actionPendingKey: string; + actionPendingKind: ScopeManagementActionKind; +} + export interface CoordinatorAdminState { activeSection: AdminSection; inviteGroup: string; @@ -38,6 +60,8 @@ export interface CoordinatorAdminState { deviceRenameDrafts: Map; groupPreferencesOpen: Set; groupPreferencesDrafts: Map; + groupScopeManagementOpen: Set; + groupScopeManagementDrafts: Map; /** * Cached list of project names from /api/projects so the scope-defaults * ProjectScopePicker can render them as clickable chips without @@ -67,5 +91,7 @@ export const coordinatorAdminState: CoordinatorAdminState = { deviceRenameDrafts: new Map(), groupPreferencesOpen: new Set(), groupPreferencesDrafts: new Map(), + groupScopeManagementOpen: new Set(), + groupScopeManagementDrafts: new Map(), availableProjects: [], }; diff --git a/packages/ui/static/index.html b/packages/ui/static/index.html index b1a01098..af1eba8c 100644 --- a/packages/ui/static/index.html +++ b/packages/ui/static/index.html @@ -801,6 +801,59 @@ gap: var(--sp-3); } + .coordinator-admin-domain-management { + gap: var(--sp-4); + } + + .coordinator-admin-scope-card-list { + display: grid; + gap: var(--sp-3); + } + + .coordinator-admin-scope-card { + background: color-mix(in srgb, var(--surface-1) 72%, var(--surface-0)); + } + + .coordinator-admin-scope-member-list { + display: grid; + gap: var(--sp-2); + padding-top: var(--sp-2); + } + + .coordinator-admin-scope-member-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sp-3); + border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + border-radius: var(--radius-md); + padding: var(--sp-2) var(--sp-3); + background: var(--surface-0); + } + + .coordinator-admin-scope-member-copy { + display: grid; + gap: 3px; + min-width: 0; + } + + .coordinator-admin-scope-member-copy strong { + font-size: 13px; + } + + .coordinator-admin-scope-member-copy span { + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.35; + } + + @media (max-width: 720px) { + .coordinator-admin-scope-member-row { + align-items: flex-start; + flex-direction: column; + } + } + .coordinator-admin-empty-state { padding: var(--sp-3) 0 var(--sp-1); }