create-safest-tools 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/template/README.md +1 -1
  5. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  6. package/template/console/analytics/controller.ts +10 -2
  7. package/template/console/analytics/types.ts +7 -0
  8. package/template/console/appeals/AppealFollowup.tsx +171 -0
  9. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  10. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  11. package/template/console/appeals/appeal-corrections.css +29 -0
  12. package/template/console/appeals/controller.ts +40 -14
  13. package/template/console/appeals/events.ts +1 -0
  14. package/template/console/appeals/types.ts +6 -0
  15. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  16. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  17. package/template/console/command/CommandCentre.tsx +12 -6
  18. package/template/console/command/controller.ts +13 -9
  19. package/template/console/command/presentation.ts +1 -6
  20. package/template/console/command/store.ts +1 -0
  21. package/template/console/command/types.ts +1 -0
  22. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  23. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  24. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  25. package/template/console/configuration/types.ts +1 -1
  26. package/template/console/lib/priority.ts +13 -0
  27. package/template/console/operations/CaseEscalations.tsx +142 -0
  28. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  29. package/template/console/operations/case-escalations.css +20 -0
  30. package/template/console/people/PeopleWorkspace.tsx +8 -4
  31. package/template/console/people/PersonAccessDialog.tsx +40 -0
  32. package/template/console/people/types.ts +1 -0
  33. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  34. package/template/console/registry/IntegrationSetup.tsx +41 -0
  35. package/template/console/registry/IntegrationVerification.tsx +54 -0
  36. package/template/console/registry/RegistryDetailsDialog.tsx +1 -1
  37. package/template/console/registry/RegistryDialog.tsx +34 -4
  38. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  39. package/template/console/registry/integration-verification.css +25 -0
  40. package/template/console/reports/CaseActions.tsx +81 -0
  41. package/template/console/reports/CaseContinuity.tsx +27 -0
  42. package/template/console/reports/CaseNotices.tsx +48 -0
  43. package/template/console/reports/CaseOwnership.tsx +78 -0
  44. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  45. package/template/console/reports/InvestigationBrief.tsx +29 -0
  46. package/template/console/reports/ReportDrawer.tsx +109 -67
  47. package/template/console/reports/ReportWorkspace.tsx +11 -12
  48. package/template/console/reports/controller.ts +96 -49
  49. package/template/console/reports/events.ts +9 -2
  50. package/template/console/reports/load-inbox.ts +17 -0
  51. package/template/console/reports/private-drafts.ts +32 -0
  52. package/template/console/reports/store.ts +2 -2
  53. package/template/console/reports/types.ts +41 -2
  54. package/template/console/reports/use-editing-access.ts +13 -0
  55. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  56. package/template/console/shell/WorkspaceShell.tsx +55 -29
  57. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  58. package/template/console/shell/controller.ts +22 -8
  59. package/template/console/shell/navigation.ts +30 -39
  60. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  61. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  62. package/template/console/workflows/PlaybookFields.tsx +109 -0
  63. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  64. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  65. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  66. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  67. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  68. package/template/console/workflows/templates.ts +3 -3
  69. package/template/console/workflows/types.ts +11 -0
  70. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  71. package/template/migrations/0003_action_confirmation.sql +24 -0
  72. package/template/migrations/0004_notice_recovery.sql +22 -0
  73. package/template/migrations/0005_appeal_continuity.sql +17 -0
  74. package/template/migrations/0006_case_ownership.sql +23 -0
  75. package/template/migrations/0007_integration_verification.sql +21 -0
  76. package/template/migrations/0008_case_escalations.sql +135 -0
  77. package/template/migrations/0009_participant_continuation.sql +61 -0
  78. package/template/migrations/0010_appeal_corrections.sql +41 -0
  79. package/template/npm-shrinkwrap.json +8036 -0
  80. package/template/package.json +25 -17
  81. package/template/public/_headers +16 -1
  82. package/template/public/console/auth-shell.js +18701 -11800
  83. package/template/public/console/release.json +1 -0
  84. package/template/public/console/workspace-features.css +2 -0
  85. package/template/public/embed/embed.css +3 -0
  86. package/template/public/embed/embed.js +19 -1
  87. package/template/public/embed/index.html +2 -0
  88. package/template/public/index.html +9 -6
  89. package/template/public/participant.css +2 -0
  90. package/template/public/participant.html +13 -0
  91. package/template/public/participant.js +111 -0
  92. package/template/public/public-report.js +21 -1
  93. package/template/public/report/index.html +1 -1
  94. package/template/public/styles.css +329 -10
  95. package/template/public/workspace.css +124 -0
  96. package/template/scripts/reports-plan.mjs +1 -1
  97. package/template/shared/case-escalations.ts +31 -0
  98. package/template/shared/integration-progress.ts +27 -0
  99. package/template/shared/notice-inputs.ts +3 -0
  100. package/template/shared/playbook-resources.ts +14 -0
  101. package/template/shared/playbooks.ts +249 -0
  102. package/template/src/agent-executor.ts +25 -8
  103. package/template/src/ai-observability.ts +14 -13
  104. package/template/src/ai-registry-validation.ts +1 -1
  105. package/template/src/appeal-continuity.ts +75 -0
  106. package/template/src/appeal-correction-state.ts +46 -0
  107. package/template/src/appeal-corrections.ts +112 -0
  108. package/template/src/appeal-decisions.ts +85 -0
  109. package/template/src/assistant.ts +3 -4
  110. package/template/src/case-escalations.ts +223 -0
  111. package/template/src/case-investigator.ts +179 -0
  112. package/template/src/component-executor.ts +3 -2
  113. package/template/src/configuration-registry.ts +4 -1
  114. package/template/src/database-readiness.ts +8 -3
  115. package/template/src/human-tasks.ts +152 -55
  116. package/template/src/index.ts +178 -18
  117. package/template/src/installation-admin.ts +2 -1
  118. package/template/src/integration-progress.ts +45 -0
  119. package/template/src/integration-verification.ts +249 -0
  120. package/template/src/investigation-evidence.ts +95 -0
  121. package/template/src/investigator-fixture.ts +30 -0
  122. package/template/src/notice-delivery.ts +19 -0
  123. package/template/src/notice-recovery.ts +81 -0
  124. package/template/src/operator-scope.ts +31 -0
  125. package/template/src/participant-access.ts +163 -0
  126. package/template/src/participant-case.ts +87 -0
  127. package/template/src/participant-customer-access.ts +93 -0
  128. package/template/src/participant-recovery.ts +43 -0
  129. package/template/src/participant-routes.ts +67 -0
  130. package/template/src/playbook-resources.ts +58 -0
  131. package/template/src/report-admin.ts +9 -2
  132. package/template/src/report-auth.ts +1 -28
  133. package/template/src/report-delivery.ts +136 -129
  134. package/template/src/report-email-template.ts +5 -2
  135. package/template/src/report-governance.ts +42 -45
  136. package/template/src/report-operations.ts +16 -63
  137. package/template/src/report-ownership.ts +64 -0
  138. package/template/src/report-repository.ts +16 -7
  139. package/template/src/report-types.ts +1 -0
  140. package/template/src/report-workflow.ts +181 -274
  141. package/template/src/workflow-actions.ts +248 -156
  142. package/template/src/workflow-compiler.ts +63 -12
  143. package/template/src/workflow-dynamic-runtime.ts +18 -7
  144. package/template/src/workflow-effects.ts +95 -18
  145. package/template/src/workflow-event-transport.ts +16 -0
  146. package/template/src/workflow-platform-types.ts +3 -0
  147. package/template/src/workflow-platform-validation.ts +23 -1
  148. package/template/src/workflow-repository.ts +131 -28
  149. package/template/src/workflow-runs.ts +9 -4
  150. package/template/src/workflow-simulator.ts +7 -4
  151. package/template/src/workspace-governance.ts +6 -4
  152. package/template/tsconfig.json +1 -0
  153. package/template/vite.console.config.ts +30 -2
  154. package/template/worker-configuration.d.ts +3 -2
@@ -4,6 +4,7 @@ import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { configurationActionPath, configurationDefault, configurationEndpoint, configurationKeySlug, configurationLabel } from "./api";
5
5
  import { announceConfigurationChanged, configurationEvents } from "./events";
6
6
  import { editableReportForm, FormBuilder, reportFormContent, type EditableReportForm } from "./FormBuilder";
7
+ import { MessageBuilder, PolicyBuilder, RoutingBuilder } from "./ConfigurationBuilders";
7
8
  import type {
8
9
  ConfigurationDetailResponse,
9
10
  ConfigurationDraft,
@@ -15,6 +16,7 @@ import type {
15
16
  ConfigurationVersion,
16
17
  JsonObject,
17
18
  QueueListResponse,
19
+ QueueSummary,
18
20
  } from "./types";
19
21
 
20
22
  interface EditorRequest { type: ConfigurationType; key: string; exists: boolean }
@@ -51,10 +53,12 @@ export function ConfigurationDialog() {
51
53
  const [published, setPublished] = useState<ConfigurationVersion | null>(null);
52
54
  const [form, setForm] = useState<EditableReportForm>(() => editableReportForm(configurationDefault("form")));
53
55
  const [contentJson, setContentJson] = useState("{}");
56
+ const [queues, setQueues] = useState<QueueSummary[]>([]);
54
57
  const [baseline, setBaseline] = useState("");
55
58
  const [validation, setValidation] = useState<ConfigurationValidation | null>(null);
56
59
  const [validationMessage, setValidationMessage] = useState("");
57
60
  const [loading, setLoading] = useState(false);
61
+ const [loadFailed, setLoadFailed] = useState(false);
58
62
  const [action, setAction] = useState<"" | "save" | "validate" | "publish">("");
59
63
  const [error, setError] = useState("");
60
64
  const busy = loading || Boolean(action);
@@ -65,7 +69,7 @@ export function ConfigurationDialog() {
65
69
  catch { return null; }
66
70
  }, [contentJson, form, visualForm]);
67
71
  const serialized = currentContent ? JSON.stringify(currentContent) : "";
68
- const dirty = Boolean(editor && !loading && (serialized !== baseline || (!keyLocked && key !== editor.key)));
72
+ const dirty = Boolean(editor && !loading && !loadFailed && (serialized !== baseline || (!keyLocked && key !== editor.key)));
69
73
 
70
74
  const clear = (): void => {
71
75
  setEditor(null); setKey(""); setKeyEdited(false); setKeyLocked(false); setDraft(null); setPublished(null);
@@ -90,16 +94,16 @@ export function ConfigurationDialog() {
90
94
  useEffect(() => {
91
95
  const dialog = dialogRef.current;
92
96
  if (!dialog) return;
93
- if (editor && !dialog.open) { dialog.showModal(); requestAnimationFrame(() => keyRef.current?.focus()); }
97
+ if (editor && !dialog.open) { dialog.showModal(); }
94
98
  if (!editor && dialog.open) dialog.close();
95
99
  }, [editor]);
96
100
  useEffect(() => {
97
101
  if (!editor) return;
98
102
  let active = true;
99
103
  const load = async (): Promise<void> => {
100
- setLoading(true); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
104
+ setLoading(true); setLoadFailed(false); setError(""); setDraft(null); setPublished(null); setValidation(null); setValidationMessage(""); setBaseline("");
101
105
  try {
102
- const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues").catch(() => ({ queues: [] })) : { queues: [] };
106
+ const queueResponse = editor.type === "routing_rules" ? await requestJson<QueueListResponse>("/v1/admin/queues") : { queues: [] };
103
107
  const fallback = configurationDefault(editor.type, queueResponse.queues || []);
104
108
  let content = fallback;
105
109
  let nextDraft: ConfigurationDraft | null = null;
@@ -111,6 +115,7 @@ export function ConfigurationDialog() {
111
115
  content = nextDraft?.content || nextPublished?.content || fallback;
112
116
  }
113
117
  if (!active) return;
118
+ setQueues(queueResponse.queues || []);
114
119
  const resolvedKey = editor.type === "routing_rules" ? "default" : editor.key;
115
120
  setKey(resolvedKey); setKeyEdited(Boolean(resolvedKey)); setKeyLocked(editor.type === "routing_rules" || editor.exists);
116
121
  setDraft(nextDraft); setPublished(nextPublished); setContentJson(JSON.stringify(content, null, 2));
@@ -121,7 +126,7 @@ export function ConfigurationDialog() {
121
126
  setValidationMessage(nextDraft.validation.valid ? "Stored check passed." : `${nextDraft.validation.errors?.length || 0} stored problems.`);
122
127
  } else if (nextPublished) setValidationMessage(`Based on published version ${nextPublished.version}. Save to create an editable draft.`);
123
128
  else setValidationMessage("Not saved yet.");
124
- } catch (cause) { if (active) setError(errorMessage(cause, "The configuration draft could not be opened.")); }
129
+ } catch (cause) { if (active) { setLoadFailed(true); setError(errorMessage(cause, "The configuration draft could not be opened.")); } }
125
130
  finally { if (active) setLoading(false); }
126
131
  };
127
132
  void load();
@@ -129,8 +134,9 @@ export function ConfigurationDialog() {
129
134
  }, [editor]);
130
135
 
131
136
  const save = async (): Promise<ConfigurationSaveResponse | null> => {
137
+ if (loadFailed || dialogRef.current?.querySelector("form")?.reportValidity() === false) return null;
132
138
  if (!editor || !currentContent) { setError("Configuration JSON must be a valid object."); return null; }
133
- const resolvedKey = type === "routing_rules" ? "default" : key.trim();
139
+ const resolvedKey = type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, "");
134
140
  if (!resolvedKey) { setError("An internal name is required."); keyRef.current?.focus(); return null; }
135
141
  if (draft && serialized === baseline) return { draftId: draft.id, revision: draft.revision, validation: draft.validation };
136
142
  setAction("save"); setError("");
@@ -157,7 +163,7 @@ export function ConfigurationDialog() {
157
163
  if (!saved || !editor) return null;
158
164
  setAction("validate"); setError("");
159
165
  try {
160
- const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "validate"), { method: "POST" });
166
+ const result = await requestJson<ConfigurationValidationResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "validate"), { method: "POST" });
161
167
  setValidation(result.validation); setValidationMessage(result.validation.valid ? "Ready to publish." : "Changes needed.");
162
168
  setDraft((current) => current ? { ...current, validation: result.validation, validationStatus: result.validation.valid ? "valid" : "invalid" } : current);
163
169
  return { validation: result.validation, saved };
@@ -174,7 +180,7 @@ export function ConfigurationDialog() {
174
180
  if (!window.confirm("Publish this immutable configuration version for future reports and workflow runs?")) return;
175
181
  setAction("publish"); setError("");
176
182
  try {
177
- const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : key, "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
183
+ const result = await requestJson<ConfigurationPublishResponse>(configurationActionPath(type, type === "routing_rules" ? "default" : keyLocked ? key : configurationKeySlug(key, ""), "publish"), { method: "POST", headers: mutationHeaders(), body: JSON.stringify({ expected_revision: checked.saved.revision }) });
178
184
  setPublished({ id: result.versionId, version: result.version, content: currentContent || draft?.content || {}, publishedAt: new Date().toISOString() });
179
185
  setValidationMessage(`Published version ${result.version}. Future reports and workflow runs can use it; historical work does not change.`);
180
186
  announceConfigurationChanged();
@@ -186,14 +192,23 @@ export function ConfigurationDialog() {
186
192
  if (!keyLocked && !keyEdited && next.title !== form.title) setKey(configurationKeySlug(next.title, ""));
187
193
  setForm(next); setValidation(null); setValidationMessage("Unsaved changes.");
188
194
  };
195
+ const updateContent = (next: JsonObject): void => {
196
+ if (type === "policy" && !keyLocked && !keyEdited && next.title !== currentContent?.title) setKey(String(next.title || ""));
197
+ setContentJson(JSON.stringify(next, null, 2)); setValidation(null); setValidationMessage("Unsaved changes.");
198
+ };
189
199
  const title = `${editor?.exists ? "Edit" : "New"} ${configurationLabel(type).toLowerCase()}`;
190
- const subtitle = loading ? "Loading configuration…" : `${key || "Unsaved draft"}${draft ? ` · Draft revision ${draft.revision}` : published ? ` · Based on published v${published.version}` : ""}`;
200
+ const subtitle = loading ? "Loading…" : draft ? "Draft · Changes are not live until published" : published ? `Published version ${published.version} · Create an updated version` : "Draft · Changes are not live until published";
191
201
  return <dialog ref={dialogRef} className="editor-dialog configuration-dialog" aria-labelledby="configuration-dialog-title" onCancel={(event) => { event.preventDefault(); close(); }} data-react-slice="configuration-dialog">
192
- <div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
193
- {loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
194
- <label>Internal name <small>Used to identify this configuration in Safest.</small><input ref={keyRef} pattern="[a-z0-9]+(?:-[a-z0-9]+)*" required readOnly={keyLocked} disabled={busy} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label>
195
- {visualForm ? <FormBuilder value={form} onChange={updateForm} disabled={busy} /> : <label>Configuration JSON<textarea className="code-input configuration-code" spellCheck={false} required disabled={busy} value={contentJson} onChange={(event) => { setContentJson(event.currentTarget.value); setValidation(null); setValidationMessage("Unsaved changes."); }} /></label>}
202
+ <div className="detail-head"><div><h2 id="configuration-dialog-title">{title}</h2><p>{subtitle}</p></div><button className="icon-button" autoFocus type="button" aria-label="Close" disabled={busy} onClick={close}>×</button></div>
203
+ {loading ? <div className="configuration-dialog-loading"><div className="skeleton-block" role="status" aria-label="Loading configuration editor" /></div> : loadFailed ? <div className="configuration-fields"><p className="error" role="alert">{error}</p><button className="secondary" type="button" onClick={() => setEditor(current => current ? { ...current } : current)}>Try again</button></div> : <form className="editor-form configuration-form" onSubmit={(event: FormEvent<HTMLFormElement>) => { event.preventDefault(); void save(); }}>
204
+ <div className="configuration-fields">
205
+ {!keyLocked ? <label>Configuration name<small>A name your team will recognise.</small><input ref={keyRef} required disabled={busy} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label> : null}
206
+ {visualForm ? <FormBuilder value={form} onChange={updateForm} disabled={busy} /> : <>
207
+ {currentContent ? type === "routing_rules" ? <RoutingBuilder value={currentContent} onChange={updateContent} disabled={busy} queues={queues} /> : type === "policy" ? <PolicyBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <MessageBuilder value={currentContent} onChange={updateContent} disabled={busy} /> : <p className="error" role="alert">Correct the advanced configuration to continue editing.</p>}
208
+ <details className="configuration-advanced"><summary>Advanced configuration</summary><p>For custom fields, saved routing examples, and intake enrichments. Changes here update the form above.</p><label>Configuration JSON<textarea className="code-input configuration-code" spellCheck={false} required disabled={busy} value={contentJson} onChange={(event) => { setContentJson(event.currentTarget.value); setValidation(null); setValidationMessage("Unsaved changes."); }} /></label></details>
209
+ </>}
196
210
  <ValidationSummary validation={validation} message={validationMessage} />
211
+ </div>
197
212
  <div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={busy} onClick={close}>Cancel</button><button className="secondary" type="submit" disabled={busy || !dirty}>{action === "save" ? "Saving…" : "Save draft"}</button><button className="secondary" type="button" disabled={busy} onClick={() => void validate()}>{action === "validate" ? "Checking…" : "Check"}</button><button className="primary" type="button" disabled={busy} onClick={() => void publish()}>{action === "publish" ? "Publishing…" : "Publish"}</button></div></div>
198
213
  </form>}
199
214
  </dialog>;
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
3
  import { readable, relativeTime } from "../lib/format";
4
4
  import { errorMessage, requestJson } from "../lib/http";
@@ -16,7 +16,7 @@ const tabs: Array<{ type: ConfigurationType; label: string }> = [
16
16
 
17
17
  function ConfigurationCard({ item, type }: { item: ConfigurationSummary; type: ConfigurationType }) {
18
18
  const updated = item.draft?.updatedAt || item.latestPublishedAt || "";
19
- const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey);
19
+ const title = item.configurationKey === "default" ? "Workspace routing" : readable(item.configurationKey.replaceAll("-", " "));
20
20
  return <article className="object-card configuration-card">
21
21
  <div className="object-card-head"><span className="object-icon" aria-hidden="true">⌗</span><span className={`badge${item.draft?.validationStatus === "invalid" ? " urgent" : ""}`}>{item.draft ? "Draft" : "Published"}</span></div>
22
22
  <h3>{title}</h3><p>{item.draft ? `Revision ${item.draft.revision} · ${readable(item.draft.validationStatus)}` : "No unpublished changes."}</p>
@@ -33,19 +33,27 @@ export function ConfigurationWorkspace() {
33
33
  const [loading, setLoading] = useState(false);
34
34
  const [refreshing, setRefreshing] = useState(false);
35
35
  const [error, setError] = useState("");
36
+ const requestId = useRef(0);
37
+ const selectType = (next: ConfigurationType): void => {
38
+ if (next === type) return;
39
+ requestId.current += 1;
40
+ setConfigurations([]); setError(""); setLoading(true); setType(next);
41
+ };
36
42
 
37
43
  const load = useCallback(async (refresh = false): Promise<void> => {
44
+ const id = ++requestId.current;
38
45
  if (!canManage) { setConfigurations([]); return; }
39
46
  if (refresh) setRefreshing(true); else setLoading(true);
40
47
  setError("");
41
48
  try {
42
49
  const result = await requestJson<ConfigurationListResponse>(`/v1/admin/${configurationEndpoint(type)}`);
50
+ if (id !== requestId.current) return;
43
51
  setConfigurations(result.configurations || []);
44
- } catch (cause) { setError(errorMessage(cause, "Configuration could not be loaded.")); }
45
- finally { setLoading(false); setRefreshing(false); }
52
+ } catch (cause) { if (id === requestId.current) setError(errorMessage(cause, "Configuration could not be loaded.")); }
53
+ finally { if (id === requestId.current) { setLoading(false); setRefreshing(false); } }
46
54
  }, [canManage, type]);
47
55
 
48
- useEffect(() => { if (shell.currentView === "configuration") void load(); }, [shell.currentView, shell.session?.actor.id, load]);
56
+ useEffect(() => { if (shell.currentView === "configuration") void load(); return () => { requestId.current += 1; }; }, [shell.currentView, shell.session?.actor.id, load]);
49
57
  useEffect(() => {
50
58
  const changed = (): void => { if (shell.currentView === "configuration") void load(true); };
51
59
  window.addEventListener(configurationEvents.changed, changed);
@@ -56,10 +64,15 @@ export function ConfigurationWorkspace() {
56
64
  const create = (): void => openConfigurationEditor(type, type === "routing_rules" ? "default" : "", type === "routing_rules" && Boolean(routing));
57
65
  return <div className="page-stack" data-react-slice="configuration-workspace">
58
66
  <PageHeader titleId="configuration-title" title="Configuration" description="Manage intake forms, routing, policies, and messages.">
59
- <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
67
+ <div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button><button className="primary" type="button" disabled={!canManage || loading || refreshing} onClick={create}>{type === "routing_rules" && routing ? "Open routing" : "Create draft"}</button></div>
60
68
  </PageHeader>
61
- <div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} onClick={() => setType(tab.type)} key={tab.type}>{tab.label}</button>)}</div>
62
- {type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safeguards run before AI.</strong><span>The router sees only approved fields and queues. Uncertain results go to General triage.</span></div> : null}
69
+ <div className="segmented-tabs configuration-tabs" role="tablist" aria-label="Configuration type">{tabs.map((tab, index) => <button className={type === tab.type ? "active" : ""} type="button" role="tab" aria-selected={type === tab.type} tabIndex={type === tab.type ? 0 : -1} onClick={() => selectType(tab.type)} onKeyDown={event => {
70
+ const next = event.key === "ArrowRight" ? (index + 1) % tabs.length : event.key === "ArrowLeft" ? (index + tabs.length - 1) % tabs.length : event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : -1;
71
+ if (next < 0) return;
72
+ event.preventDefault(); selectType(tabs[next]!.type);
73
+ event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>('button[role="tab"]')[next]?.focus();
74
+ }} key={tab.type}>{tab.label}</button>)}</div>
75
+ {type === "routing_rules" ? <div className="safety-boundary routing-boundary"><strong>Safety review comes first.</strong><span>Safeguards send sensitive cases to human reviewers. Other cases follow your routing rules and default queue.</span></div> : null}
63
76
  <div className="object-grid configuration-list" aria-busy={loading || refreshing}>{loading ? <div className="skeleton-card" /> : configurations.length ? configurations.map((item) => <ConfigurationCard item={item} type={type} key={item.configurationKey} />) : error ? null : <div className="empty-product"><span aria-hidden="true">⌗</span><h2>No {configurationLabel(type).toLowerCase()} yet</h2><p>Create, check, and publish an immutable version.</p>{canManage ? <button className="primary" type="button" onClick={create}>Create draft</button> : null}</div>}</div>
64
77
  <p className="error surface-error" role="alert">{error}</p>
65
78
  </div>;
@@ -75,5 +75,5 @@ export interface ReportFormContent {
75
75
  consent_notice: string;
76
76
  }
77
77
 
78
- export interface QueueSummary { id: string; name: string; status: string }
78
+ export interface QueueSummary { id: string; name: string; status: string; activePolicy?: { managementMode: string } | null }
79
79
  export interface QueueListResponse { queues: QueueSummary[] }
@@ -0,0 +1,13 @@
1
+ export type Priority = "urgent" | "high" | "medium" | "low";
2
+
3
+ export function priorityName(priority: number): Priority {
4
+ if (priority >= 80) return "urgent";
5
+ if (priority >= 60) return "high";
6
+ if (priority >= 40) return "medium";
7
+ return "low";
8
+ }
9
+
10
+ export function priorityLabel(priority: number): string {
11
+ const name = priorityName(priority);
12
+ return name[0].toUpperCase() + name.slice(1);
13
+ }
@@ -0,0 +1,142 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore, type FormEvent } from 'react';
2
+ import { getShellSnapshot, subscribeToShell } from '../shell/store';
3
+ import { errorMessage, mutationHeaders, requestJson } from '../lib/http';
4
+ import { dispatchOpenReportSection } from '../reports/events';
5
+ import { dispatchOpenAppeal } from '../appeals/events';
6
+ import { dispatchNavigation } from '../shell/events';
7
+ import type { CaseEscalationItem, CaseEscalationList, CaseEscalationPolicy, EscalationContact } from '../../shared/case-escalations';
8
+ import './case-escalations.css';
9
+
10
+ interface Settings { policies: CaseEscalationPolicy[]; contacts: EscalationContact[]; queues: { id:string;name:string }[] }
11
+ interface RecoveryTask { id:string;state:string;queueId:string|null;taskType:string;context?:{title?:string;instructions?:string} }
12
+ const stamp = (value: string) => new Date(value).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
13
+ function recover(item: CaseEscalationItem, recovery: CaseEscalationItem['recovery'][number]) {
14
+ if (recovery.type === 'people') dispatchNavigation('people');
15
+ else if (recovery.type === 'appeal') dispatchOpenAppeal(item.sourceId);
16
+ else if (item.reportId) dispatchOpenReportSection(item.reportId,recovery.section ?? 'overview',recovery.type === 'task' ? item.sourceId : undefined);
17
+ }
18
+
19
+ function TaskRecovery({ item,onClose,onChanged }: { item:CaseEscalationItem;onClose:()=>void;onChanged:()=>void }) {
20
+ const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
21
+ const [task,setTask] = useState<RecoveryTask|null>(null),[queues,setQueues] = useState<Settings['queues']>([]);
22
+ const [queue,setQueue] = useState(''),[reason,setReason] = useState(''),[action,setAction] = useState('reassign');
23
+ const [error,setError] = useState(''),[busy,setBusy] = useState(false);
24
+ const [retry,setRetry] = useState(0);
25
+ const canAssign = Boolean(shell.session?.permissions.assignTasks),canCancel = Boolean(shell.session?.permissions.operateRuns);
26
+ useEffect(() => {
27
+ const controller = new AbortController();setError('');
28
+ Promise.all([requestJson<{task:RecoveryTask}>(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}`,{signal:controller.signal}),canAssign ? requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}) : Promise.resolve({queues:[]})])
29
+ .then(([result,settings]) => {if (!controller.signal.aborted) {setTask(result.task);setQueue(result.task.queueId ?? '');setQueues(settings.queues);setAction(canAssign ? 'reassign' : 'cancel');}})
30
+ .catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'The task could not be loaded.'));});
31
+ return () => controller.abort();
32
+ },[item.sourceId,canAssign,retry]);
33
+ async function submit(event:FormEvent) {
34
+ event.preventDefault();setBusy(true);setError('');
35
+ try {await requestJson(`/v1/admin/tasks/${encodeURIComponent(item.sourceId)}/${action}`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({reason, ...(action === 'reassign' ? {queue_id:queue} : {})})});onChanged();onClose();}
36
+ catch(cause) {setError(errorMessage(cause,'The task changed or could not be recovered.'));} finally {setBusy(false);}
37
+ }
38
+ return <form className="case-escalation-settings" aria-label="Task recovery" onSubmit={event => {void submit(event);}}>
39
+ <h3>{task?.context?.title ?? 'Recover playbook task'}</h3>{task?.context?.instructions ? <p>{task.context.instructions}</p> : null}
40
+ {task ? <><p>Current state: {task.state}. Reassignment releases current editing access. Cancellation ends this task and sends the playbook a cancellation event; it does not complete the case.</p><label>Recovery action<select value={action} disabled={busy} onChange={event => setAction(event.currentTarget.value)}>{canAssign ? <option value="reassign">Return to an eligible queue</option> : null}{canCancel ? <option value="cancel">Cancel this task</option> : null}</select></label>{action === 'reassign' ? <label>Destination queue<select required disabled={busy} value={queue} onChange={event => setQueue(event.currentTarget.value)}><option value="">Choose a queue</option>{queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label> : null}<label>Recovery reason<textarea required minLength={3} maxLength={2000} rows={3} disabled={busy} value={reason} onChange={event => setReason(event.currentTarget.value)} /></label></> : !error ? <p role="status">Loading task…</p> : null}
41
+ {error ? <p className="error" role="alert">{error}{!task ? <button className="text-button" type="button" onClick={() => setRetry(value => value + 1)}>Retry loading task</button> : null}</p> : null}
42
+ <div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Close task recovery</button><button type="submit" className="primary" disabled={busy || !task || !['open','claimed'].includes(task.state) || !reason.trim()}>{busy ? 'Saving…' : action === 'cancel' ? 'Cancel task with reason' : 'Reassign task with reason'}</button></div>
43
+ </form>;
44
+ }
45
+
46
+ function EscalationSettings({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
47
+ const [settings,setSettings] = useState<Settings | null>(null);
48
+ const [queue,setQueue] = useState('');
49
+ const [primary,setPrimary] = useState('');
50
+ const [backup,setBackup] = useState('');
51
+ const [acknowledgement,setAcknowledgement] = useState(60);
52
+ const [followUp,setFollowUp] = useState(240);
53
+ const [error,setError] = useState('');
54
+ const [busy,setBusy] = useState(false);
55
+ const [retry,setRetry] = useState(0);
56
+ useEffect(() => {
57
+ const controller = new AbortController();
58
+ requestJson<Settings>('/v1/admin/case-escalation-policies',{signal:controller.signal}).then(result => {setSettings(result);setError('');if (!result.policies.some(value => value.queueId === null) && result.queues[0]) setQueue(result.queues[0].id);}).catch(cause => {if (!controller.signal.aborted) setError(errorMessage(cause,'Recovery contacts could not be loaded.'));});
59
+ return () => controller.abort();
60
+ },[retry]);
61
+ useEffect(() => {
62
+ const policy = settings?.policies.find(value => value.queueId === (queue || null));
63
+ setPrimary(policy?.primaryOperatorId ?? '');setBackup(policy?.backupOperatorId ?? '');setAcknowledgement(policy?.acknowledgementMinutes ?? 60);setFollowUp(policy?.followUpMinutes ?? 240);
64
+ },[settings,queue]);
65
+ async function save(event: FormEvent) {
66
+ event.preventDefault();setBusy(true);setError('');
67
+ try {
68
+ const policy = settings?.policies.find(value => value.queueId === (queue || null));
69
+ await requestJson('/v1/admin/case-escalation-policies',{method:'PUT',headers:mutationHeaders(),body:JSON.stringify({queueId:queue || null,primaryOperatorId:primary || null,backupOperatorId:backup || null,acknowledgementMinutes:acknowledgement,followUpMinutes:followUp,revision:policy?.revision ?? 0})});
70
+ onSaved();onClose();
71
+ } catch(cause) {setError(errorMessage(cause,'Recovery contacts could not be saved.'));} finally {setBusy(false);}
72
+ }
73
+ return <form className="case-escalation-settings" aria-label="Recovery contacts" onSubmit={event => {void save(event);}}>
74
+ <h3>Recovery contacts and deadlines</h3><p>The coordinator arranges recovery. Case ownership and approval permissions stay with their current holders.</p>
75
+ {!settings && !error ? <p role="status">Loading recovery contacts…</p> : settings ? <>
76
+ <label>Queue<select value={queue} disabled={busy} onChange={event => setQueue(event.currentTarget.value)}>{settings.policies.some(value => value.queueId === null) ? <option value="">Installation default</option> : null}{settings.queues.map(value => <option key={value.id} value={value.id}>{value.name}</option>)}</select></label>
77
+ <div className="case-escalation-setting-grid">
78
+ <label>Primary contact<select value={primary} disabled={busy} onChange={event => setPrimary(event.currentTarget.value)}><option value="">Case owner, then eligible administrators</option>{settings.contacts.map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
79
+ <label>Backup contact<select value={backup} disabled={busy} onChange={event => setBackup(event.currentTarget.value)}><option value="">Eligible owners and administrators</option>{settings.contacts.filter(person => person.id !== primary).map(person => <option key={person.id} value={person.id}>{person.displayName}</option>)}</select></label>
80
+ <label>Acknowledge within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={acknowledgement} onChange={event => setAcknowledgement(Number(event.currentTarget.value))} /></label>
81
+ <label>Follow up within (minutes)<input type="number" min={5} max={10080} required disabled={busy} value={followUp} onChange={event => setFollowUp(Number(event.currentTarget.value))} /></label>
82
+ </div><p>If a target passes, the item escalates to the backup and stays open until its source is repaired. Named contacts must have access to the queue.</p>
83
+ </> : null}
84
+ {error ? <p role="alert" className="error">{error}{!settings ? <> <button type="button" className="text-button" onClick={() => setRetry(value => value + 1)}>Retry</button></> : null}</p> : null}
85
+ <div className="case-escalation-buttons"><button type="button" className="secondary" disabled={busy} onClick={onClose}>Cancel</button><button className="primary" type="submit" disabled={busy || !settings}>{busy ? 'Saving…' : 'Save recovery contacts'}</button></div>
86
+ </form>;
87
+ }
88
+
89
+ export function CaseEscalations({ compact = false }: { compact?: boolean }) {
90
+ const shell = useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
91
+ const active = shell.currentView === (compact ? 'command' : 'operations') && Boolean(shell.session?.permissions.readReports || shell.session?.permissions.readOperations);
92
+ const [data,setData] = useState<CaseEscalationList | null>(null);
93
+ const [error,setError] = useState('');
94
+ const [busy,setBusy] = useState('');
95
+ const [refreshing,setRefreshing] = useState(false);
96
+ const [settings,setSettings] = useState(false);
97
+ const [expanded,setExpanded] = useState(false);
98
+ const [status,setStatus] = useState('');
99
+ const [recoveryTask,setRecoveryTask] = useState<CaseEscalationItem|null>(null);
100
+ const load = useCallback(async (signal?: AbortSignal,cursor = '') => {
101
+ setRefreshing(true);
102
+ try {
103
+ const next = await requestJson<CaseEscalationList>(`/v1/admin/case-escalations${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ''}`,{signal});
104
+ if (signal?.aborted) return;
105
+ setData(previous => cursor && previous ? {...next,items:[...previous.items,...next.items.filter(item => !previous.items.some(old => old.id === item.id))]} : next);setError('');
106
+ } catch(cause) {if (!signal?.aborted) setError(errorMessage(cause,'Recovery work could not be loaded.'));} finally {if (!signal?.aborted) setRefreshing(false);}
107
+ },[]);
108
+ useEffect(() => {
109
+ if (!active) return;
110
+ const controller = new AbortController();setData(null);setStatus('');void load(controller.signal);
111
+ const refresh = () => {if (document.visibilityState === 'visible') void load(controller.signal);};
112
+ const timer = window.setInterval(refresh,60_000);window.addEventListener('focus',refresh);
113
+ return () => {controller.abort();window.clearInterval(timer);window.removeEventListener('focus',refresh);setSettings(false);};
114
+ },[active,shell.session?.actor.id,load]);
115
+ if (!active) return null;
116
+ const items = compact && !expanded ? data?.items.slice(0,3) : data?.items;
117
+ async function acknowledge(item: CaseEscalationItem) {
118
+ setBusy(item.id);setStatus('');setError('');
119
+ try {
120
+ await requestJson(`/v1/admin/case-escalations/${encodeURIComponent(item.id)}/acknowledge`,{method:'POST',headers:mutationHeaders(),body:JSON.stringify({revision:item.revision})});
121
+ setStatus('You are responsible for recovery. Open the affected work below to repair the cause.');await load();
122
+ } catch(cause) {setError(errorMessage(cause,'Responsibility could not be recorded.'));} finally {setBusy('');}
123
+ }
124
+ return <section className="surface case-escalations" aria-label="Cases needing recovery" aria-busy={refreshing}>
125
+ <div className="surface-head"><div><h2>Cases needing recovery{data ? ` (${data.total})` : ''}</h2><p>{data ? `${data.unacknowledged} awaiting acknowledgement · ${data.escalated} escalated to backup` : 'Checking stalled cases and participant delivery.'}</p></div><div className="case-escalation-buttons"><button type="button" className="secondary" disabled={refreshing || Boolean(busy)} onClick={() => {void load();}}>Refresh recovery</button>{shell.session?.permissions.manageConfiguration ? <button type="button" className="text-button" onClick={() => setSettings(value => !value)}>Recovery contacts</button> : null}</div></div>
126
+ {settings ? <EscalationSettings onClose={() => setSettings(false)} onSaved={() => {setStatus('Recovery contacts saved.');void load();}} /> : null}
127
+ {status ? <p role="status" className="case-escalation-status">{status}</p> : null}
128
+ {error ? <p role="alert" className="error">{error} <button type="button" className="text-button" onClick={() => {void load();}}>Retry loading recovery</button></p> : null}
129
+ {!data && refreshing ? <p role="status">Loading recovery work…</p> : null}
130
+ {data && !data.total ? <p className="case-escalation-empty">No detected cases need recovery. Open work continues against its configured deadlines.</p> : null}
131
+ <div className="case-escalation-list">{items?.map(item => <article className="case-escalation-item" key={item.id} aria-label={`${item.title}${item.reportReference ? `: ${item.reportReference}` : ''}`}>
132
+ <div className="case-escalation-item-head"><div><h3>{item.title}</h3><p>{item.reportReference ?? (item.reportId ? 'Case reference unavailable' : 'Workspace task')}{item.queueName ? ` · ${item.queueName}` : ''}</p></div><span className={`badge${item.level ? ' urgent' : ''}`}>{item.state === 'acknowledged' ? 'Recovery in progress' : item.level ? 'Backup needed' : 'Needs acknowledgement'}</span></div>
133
+ <p>{item.description}</p>
134
+ <dl><div><dt>Responsible</dt><dd>{item.acknowledgedBy?.displayName ?? item.responsible?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Backup</dt><dd>{item.backup?.displayName ?? 'Eligible owners and administrators'}</dd></div><div><dt>Source target</dt><dd>{stamp(item.sourceDueAt)}</dd></div><div><dt>{item.state === 'acknowledged' ? 'Follow up by' : 'Acknowledge by'}</dt><dd>{stamp(item.responseDueAt)}</dd></div></dl>
135
+ {item.contactWarning ? <p className="case-escalation-warning">{item.contactWarning}</p> : null}
136
+ {item.acknowledgedAt ? <p>Acknowledged {stamp(item.acknowledgedAt)}. This item clears when the underlying problem is repaired.</p> : null}
137
+ <div className="case-escalation-buttons">{item.canAcknowledge ? <button type="button" className="primary" disabled={Boolean(busy)} onClick={() => {void acknowledge(item);}}>{busy === item.id ? 'Recording…' : 'Take responsibility for recovery'}</button> : null}{item.recovery.map(link => <button key={link.type} className="secondary" type="button" onClick={() => link.type === 'task_recovery' ? setRecoveryTask(item) : recover(item,link)}>{link.label}</button>)}</div>
138
+ {recoveryTask?.id === item.id ? <TaskRecovery item={item} onClose={() => setRecoveryTask(null)} onChanged={() => {setStatus('Task recovery recorded. Its source will be checked again.');void load();}} /> : null}
139
+ </article>)}</div>
140
+ {compact && !expanded && (data?.total ?? 0) > 3 ? <button type="button" className="text-button" onClick={() => setExpanded(true)}>Show all recovery work</button> : data?.nextCursor ? <button type="button" className="secondary" disabled={refreshing} onClick={() => {void load(undefined,data.nextCursor!);}}>Load more recovery work</button> : null}
141
+ </section>;
142
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
+ import { CaseEscalations } from './CaseEscalations';
3
4
  import { readable, relativeTime } from "../lib/format";
4
5
  import {
5
6
  dispatchAcknowledgeOperationsIncident,
@@ -60,6 +61,7 @@ export function OperationsWorkspace() {
60
61
  <PageHeader titleId="operations-title" title="Operations" description="Review failed deliveries, exhausted retries, and other incidents.">
61
62
  <div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchOperationsRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
62
63
  </PageHeader>
64
+ <CaseEscalations />
63
65
  <section className="surface operations-surface" aria-labelledby="operations-title">
64
66
  <div className="operation-metrics" aria-busy={snapshot.loading || snapshot.refreshing}>
65
67
  <Metric label="Routing failures" value={snapshot.loading ? "—" : routingFailures} detail="Failed or dead-letter jobs" urgent={routingFailures > 0} />
@@ -0,0 +1,20 @@
1
+ .case-escalations { margin-block: 1rem; min-width: 0; padding: 1.25rem; }
2
+ .case-escalations .surface-head { gap: 1rem; flex-wrap: wrap; padding: 0 0 1rem; margin-bottom: 1rem; }
3
+ .case-escalations p { color: var(--muted, #5c6862); line-height: 1.5; }
4
+ .case-escalation-list { display: grid; gap: 1rem; }
5
+ .case-escalation-item { border: 1px solid var(--line, #d8dfda); border-radius: .75rem; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
6
+ .case-escalation-item-head { display: flex; justify-content: space-between; align-items: flex-start; gap: .75rem; flex-wrap: wrap; }
7
+ .case-escalation-item h3 { margin: 0; font-size: 1rem; }
8
+ .case-escalation-item-head p { margin: .3rem 0 0; font-size: .875rem; }
9
+ .case-escalation-item dl { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; }
10
+ .case-escalation-item dt { font-size: .8rem; color: var(--muted, #5c6862); }
11
+ .case-escalation-item dd { margin: .2rem 0 0; font-size: .9rem; }
12
+ .case-escalation-buttons { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
13
+ .case-escalation-buttons button { min-height: 44px; white-space: normal; }
14
+ .case-escalation-warning { border-inline-start: 3px solid #af7820; padding-inline-start: .75rem; }
15
+ .case-escalation-settings { padding: 1rem; margin-block: 1rem; border: 1px solid var(--line, #d8dfda); border-radius: .75rem; }
16
+ .case-escalation-settings label { display: grid; gap: .35rem; min-width: 0; font-size: .875rem; }
17
+ .case-escalation-setting-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: .75rem; margin-block: .75rem; }
18
+ .case-escalation-settings select,.case-escalation-settings input,.case-escalation-settings textarea { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
19
+ .case-escalation-status { padding: .75rem; background: var(--brand-soft, #e8f1e9); border-radius: .5rem; }
20
+ @media (max-width: 560px) { .case-escalations { padding: 1rem; } .case-escalation-setting-grid { grid-template-columns: minmax(0,1fr); } .case-escalation-buttons { align-items: stretch; } .case-escalation-buttons button { flex: 1 1 auto; } }
@@ -4,6 +4,7 @@ import { PageHeader } from "../components/PageHeader";
4
4
  import { errorMessage, postJson, requestJson } from "../lib/http";
5
5
  import { relativeTime } from "../lib/format";
6
6
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
7
+ import { PersonAccessDialog } from "./PersonAccessDialog";
7
8
  import type { InvitationResponse, PasswordResetResponse, PeopleResponse, WorkspaceInvitation, WorkspacePerson } from "./types";
8
9
 
9
10
  const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
@@ -24,6 +25,8 @@ export function PeopleWorkspace() {
24
25
  const [error, setError] = useState("");
25
26
  const [inviteUrl, setInviteUrl] = useState("");
26
27
  const [resetMessage, setResetMessage] = useState("");
28
+ const [editing, setEditing] = useState<WorkspacePerson | null>(null);
29
+ const [copied, setCopied] = useState(false);
27
30
  const canManage = shell.session?.permissions.managePeople === true;
28
31
  const isOwner = shell.session?.roles.includes("owner") === true;
29
32
 
@@ -38,10 +41,10 @@ export function PeopleWorkspace() {
38
41
  finally { setLoading(false); }
39
42
  };
40
43
 
41
- useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); void load(); }, [shell.session?.actor.id, canManage]);
44
+ useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); setEditing(null); void load(); }, [shell.session?.actor.id, canManage]);
42
45
 
43
46
  const invite = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
44
- event.preventDefault(); setPending("invite"); setError(""); setInviteUrl("");
47
+ event.preventDefault(); setPending("invite"); setError(""); setInviteUrl(""); setCopied(false);
45
48
  try {
46
49
  const result = await postJson<InvitationResponse>("/v1/admin/people", { email, role });
47
50
  setInviteUrl(result.invitation.inviteUrl); setEmail(""); await load();
@@ -71,11 +74,12 @@ export function PeopleWorkspace() {
71
74
  </PageHeader>
72
75
  <section className="surface people-surface" aria-labelledby="people-title">
73
76
  <form className="invite-form" onSubmit={invite}><label>Email<input type="email" autoComplete="off" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></label><label>Role<select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="reviewer">Analyst</option><option value="appeal_reviewer">Appeal analyst</option><option value="auditor">Auditor</option>{isOwner ? <option value="administrator">Administrator</option> : null}</select></label><button className="primary" type="submit" disabled={pending === "invite"}>{pending === "invite" ? "Sending…" : "Send invitation"}</button></form>
74
- {inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => void copy(inviteUrl)}>Copy link</button></div> : null}
77
+ {inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => { void copy(inviteUrl).then(() => setCopied(true)).catch(() => setError("Copy failed. Select the invitation link and copy it manually.")); }}>{copied ? "Copied" : "Copy link"}</button></div> : null}
75
78
  {resetMessage ? <p className="form-message" role="status">{resetMessage}</p> : null}
76
- <div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
79
+ <div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}{person.status === "suspended" ? " · Suspended" : ""}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <div className="people-actions"><button className="secondary compact-button" type="button" onClick={() => setEditing(person)}>Edit access</button><button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button></div> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
77
80
  <p className="error surface-error" role="alert">{error}</p>
78
81
  </section>
82
+ <PersonAccessDialog person={editing} owner={isOwner} onClose={() => setEditing(null)} onSaved={() => { setResetMessage("Workspace access updated."); void load(); }} />
79
83
  </div>
80
84
  );
81
85
  }
@@ -0,0 +1,40 @@
1
+ import { useEffect, useRef, useState, type FormEvent } from "react";
2
+ import { errorMessage, requestJson } from "../lib/http";
3
+ import type { WorkspacePerson } from "./types";
4
+
5
+ const roles = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", auditor: "Auditor", administrator: "Administrator", owner: "Infrastructure owner" };
6
+
7
+ export function PersonAccessDialog({ person, owner, onClose, onSaved }: { person: WorkspacePerson | null; owner: boolean; onClose: () => void; onSaved: () => void }) {
8
+ const ref = useRef<HTMLDialogElement>(null);
9
+ const [selected, setSelected] = useState<string[]>([]);
10
+ const [status, setStatus] = useState("active");
11
+ const [pending, setPending] = useState(false);
12
+ const [error, setError] = useState("");
13
+ useEffect(() => {
14
+ setSelected(person?.roles || []); setStatus(person?.status || "active"); setError("");
15
+ if (person && !ref.current?.open) ref.current?.showModal();
16
+ if (!person && ref.current?.open) ref.current.close();
17
+ }, [person]);
18
+ const close = () => { if (!pending) onClose(); };
19
+ const save = async (event: FormEvent<HTMLFormElement>) => {
20
+ event.preventDefault();
21
+ if (!person || pending) return;
22
+ if (!selected.length) { setError("Choose at least one role."); return; }
23
+ if (status === "suspended" && person.status !== "suspended" && !window.confirm(`Suspend ${person.displayName}? Their active sessions will be signed out.`)) return;
24
+ setPending(true); setError("");
25
+ try {
26
+ await requestJson(`/v1/admin/people/${encodeURIComponent(person.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ roles: selected, status }) });
27
+ onSaved(); onClose();
28
+ } catch (cause) { setError(errorMessage(cause, "Access could not be updated.")); }
29
+ finally { setPending(false); }
30
+ };
31
+ return <dialog ref={ref} className="editor-dialog" aria-labelledby="person-access-title" onCancel={(event) => { event.preventDefault(); close(); }}>
32
+ <div className="detail-head"><div><h2 id="person-access-title">Edit access</h2><p>{person?.displayName} · {person?.email}</p></div><button type="button" className="icon-button" aria-label="Close access editor" disabled={pending} onClick={close}>×</button></div>
33
+ <form className="editor-form" onSubmit={(event) => void save(event)}>
34
+ <fieldset disabled={pending}><legend>Workspace roles</legend>{Object.entries(roles).filter(([role]) => owner || !["owner", "administrator"].includes(role)).map(([role, label]) => <label className="checkbox" key={role}><input type="checkbox" checked={selected.includes(role)} onChange={(event) => { const checked = event.currentTarget.checked; setSelected((current) => checked ? [...current, role] : current.filter((value) => value !== role)); }} />{label}</label>)}</fieldset>
35
+ <label>Account status<select disabled={pending} value={status} onChange={(event) => setStatus(event.currentTarget.value)}><option value="active">Active</option><option value="suspended">Suspended</option></select></label>
36
+ <p className="form-help">Suspending access signs out active sessions. Reactivating access lets this teammate sign in again.</p>
37
+ <p role="alert" className="error">{error}</p><div className="editor-footer"><button type="button" className="secondary" disabled={pending} onClick={close}>Cancel</button><button type="submit" className="primary" disabled={pending}>{pending ? "Saving…" : "Save access"}</button></div>
38
+ </form>
39
+ </dialog>;
40
+ }
@@ -4,6 +4,7 @@ export interface WorkspacePerson {
4
4
  email: string;
5
5
  avatarUrl?: string | null;
6
6
  roles: string[];
7
+ status: "active" | "suspended";
7
8
  lastLoginAt?: string | null;
8
9
  }
9
10
 
@@ -116,7 +116,7 @@ export function ProfileWorkspace() {
116
116
  <section className="surface profile-surface" aria-labelledby="profile-title">
117
117
  {profile ? <div className="profile-layout">
118
118
  <div className="profile-photo-card">
119
- <div className="profile-avatar-preview" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
119
+ <div className="profile-avatar-preview" role="img" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
120
120
  <label className={`secondary upload-button${pending ? " disabled" : ""}`} aria-disabled={Boolean(pending)}>{pending === "upload" ? "Uploading…" : "Choose picture"}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={Boolean(pending)} onChange={(event) => void upload(event)} /></label>
121
121
  {profile.avatarUrl ? <button className="text-button" type="button" disabled={Boolean(pending)} onClick={() => void remove()}>{pending === "remove" ? "Removing…" : "Remove picture"}</button> : null}
122
122
  <small>JPEG, PNG, or WebP. Maximum 2 MB.</small>
@@ -0,0 +1,41 @@
1
+ import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
2
+ import { dateTime } from "../lib/format";
3
+ import { errorMessage, requestJson } from "../lib/http";
4
+ import { dispatchOpenReport } from "../reports/events";
5
+ import { openChannelDialog, settingsEvents } from "../settings/events";
6
+ import { dispatchNavigation } from "../shell/events";
7
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
8
+ import { registryEvents } from "./events";
9
+ import { IntegrationVerification } from "./IntegrationVerification";
10
+ import type { IntegrationProgress } from "../../shared/integration-progress";
11
+
12
+ export function IntegrationSetup({onTools}:{onTools:(kind:"enrichment"|"action")=>void}) {
13
+ const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
14
+ const permissions=shell.session?.permissions??{};
15
+ const [progress,setProgress]=useState<IntegrationProgress|null>(null),[error,setError]=useState("");
16
+ const [loading,setLoading]=useState(false);const version=useRef(0);
17
+ const access=JSON.stringify({actor:shell.session?.actor.id,permissions,scope:shell.session?.queueScope});
18
+ const load=useCallback(async()=>{const request=++version.current;setLoading(true);setError("");try{const result=await requestJson<IntegrationProgress>("/v1/admin/integration-progress");if(request===version.current)setProgress(result);}catch(cause){if(request===version.current)setError(errorMessage(cause,"Setup evidence could not refresh."));}finally{if(request===version.current)setLoading(false);}},[]);
19
+ useEffect(()=>{version.current++;setProgress(null);if(permissions.manageComponents)void load();return()=>{version.current++;};},[access,load]);
20
+ useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);};},[load]);
21
+ const [expanded,setExpanded]=useState<string|null>("intake");
22
+ const config=progress?.configuration;
23
+ const configured=(count:number|null|undefined,label:string)=>count==null?"Configuration access required":`${count} ${label}`;
24
+ const stages=[
25
+ {key:"intake" as const,title:"Receive a report",description:"Start with a hosted form or product widget. Signed customer context keeps identity and content references tied to your application.",configuration:configured(config?.reportingRoutes,"configured reporting routes"),action:"Set up reporting",allowed:permissions.manageIntegrations,run:openChannelDialog},
26
+ {key:"context" as const,title:"Bring in the right context",description:"Publish read-only customer tools for the evidence your reviewers need. Declare the fields each playbook may request.",configuration:configured(config?.customerReads,"published customer reads"),action:"Choose read tools",allowed:true,run:()=>onTools("enrichment")},
27
+ {key:"action" as const,title:"Confirm the customer action",description:"Choose exact targets and approval rules. The case waits for your application to confirm the approved change.",configuration:configured(config?.customerActions,"published customer actions"),action:"Choose action tools",allowed:true,run:()=>onTools("action")},
28
+ {key:"notice" as const,title:"Reach each participant",description:"Write separate public outcomes for the reporter and affected person. A delivery handoff stays distinct from someone reading the notice.",configuration:config?.noticeChannel==null?"Configuration access required":config.noticeChannel?"Participant channel configured":"Participant channel needed",action:"Review notice setup",allowed:permissions.manageInstallation||permissions.manageIntegrations,run:()=>dispatchNavigation("settings")},
29
+ {key:"playbook" as const,title:"Run a complete case",description:"Publish and activate a tested playbook, then submit a fresh report through your customer channel. Follow it from review to the confirmed outcome.",configuration:configured(config?.activePlaybooks,"active playbook queue assignments"),action:"Open playbooks",allowed:permissions.manageWorkflows,run:()=>dispatchNavigation("workflows")},
30
+ ];
31
+ return <>{permissions.manageIntegrations&&permissions.manageWorkflows&&permissions.readReports&&permissions.readEvidence&&progress?.canReadCases===true?<IntegrationVerification key={access}/>:null}<section className="integration-setup surface" aria-label="Customer connection setup">
32
+ <div className="surface-head"><div><h2>Customer connection setup reference</h2><p>Start with reporting. Add customer context, approved actions and participant delivery as your playbook needs them.</p></div><button className="secondary" type="button" disabled={loading} onClick={()=>void load()}>{loading?"Checking records…":"Refresh historical evidence"}</button></div>
33
+ {progress?.localSandbox?<p className="case-guidance">Local sandbox records. Model fixtures and local customer receivers do not verify production model judgement or external delivery.</p>:null}
34
+ <ol className="integration-stages" aria-busy={loading}>{stages.map((stage,index)=>{const evidence=progress?.evidence[stage.key];const open=expanded===stage.key;return <li key={stage.key}>
35
+ <h3><button type="button" className="integration-stage-toggle" aria-expanded={open} aria-controls={`integration-stage-${stage.key}`} onClick={()=>setExpanded(open?null:stage.key)}><span className="integration-stage-number" aria-hidden="true">{index+1}</span><span>{stage.title}<small>{progress?stage.configuration:"Loading configuration…"}</small></span><span aria-hidden="true">{open?"−":"+"}</span></button></h3>
36
+ <div id={`integration-stage-${stage.key}`} hidden={!open} className="integration-stage-detail"><p>{stage.description}</p><div className="integration-stage-evidence">{evidence?<><strong>Historical case evidence</strong><button className="text-button" type="button" onClick={()=>dispatchOpenReport(evidence.reportId)}>{evidence.reference}</button><small>{dateTime(evidence.observedAt)}</small></>:<span>{progress?.canReadCases===false?"Case evidence is restricted for this role":progress?"No matching case evidence in your access yet":"Checking saved evidence…"}</span>}</div>{stage.allowed?<button className="secondary" type="button" onClick={stage.run}>{stage.action}</button>:<small>Ask your workspace administrator to complete this step.</small>}</div>
37
+ </li>;})}</ol>
38
+ <p className="form-help">These are saved configuration and case records, not a live health check. Run a fresh case after changes. Contract examples alone do not complete this journey.</p>
39
+ {error?<p className="error" role="alert">{error} {progress?"Previously loaded records remain visible.":""}</p>:null}
40
+ </section></>;
41
+ }