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
@@ -1,14 +1,18 @@
1
- import { errorMessage, requestJson } from "../lib/http";
1
+ import { clearPrivateDrafts, removePrivateDraft } from "./private-drafts";
2
+ import { loadInbox } from "./load-inbox";
3
+ import { priorityName } from "../lib/priority";
4
+ import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
2
5
  import { readable } from "../lib/format";
3
6
  import { getQueueWorkspaceSnapshot, subscribeToQueueWorkspace } from "../queues/store";
4
7
  import type { QueueRecord } from "../queues/types";
5
8
  import { getShellSnapshot, setShellSnapshot, subscribeToShell } from "../shell/store";
6
- import { reportEvents } from "./events";
9
+ import { isReportSection, reportEvents } from "./events";
7
10
  import { getReportWorkspaceSnapshot, setReportWorkspaceSnapshot } from "./store";
8
11
  import type {
9
12
  HumanDecisionInput,
10
13
  PresenceAnalyst,
11
14
  ReportDetailRecord,
15
+ ReportSection,
12
16
  ReportFilters,
13
17
  ReporterMessageInput,
14
18
  ReportSummary,
@@ -18,6 +22,7 @@ import type {
18
22
  } from "./types";
19
23
 
20
24
  interface RawReport {
25
+ assignment?: RawReport["claim"];
21
26
  id: string;
22
27
  reference: string;
23
28
  targetType: string;
@@ -43,7 +48,6 @@ interface RawPresenceAnalyst {
43
48
  activeReportId?: string | null;
44
49
  }
45
50
 
46
- interface ReportsResponse { reports?: RawReport[] }
47
51
  interface ReportResponse { report: ReportDetailRecord }
48
52
  interface TimelineResponse { events?: ReportDetailRecord["timeline"] }
49
53
  interface WorkflowRunsResponse { runs?: WorkflowRunRecord[] }
@@ -51,7 +55,7 @@ interface WorkflowActionsResponse { actions?: WorkflowActionRecord[] }
51
55
  interface FindingsResponse { findings?: ReportDetailRecord["findings"] }
52
56
  interface PresenceResponse { analysts?: RawPresenceAnalyst[] }
53
57
 
54
- const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" });
58
+ const defaultFilters = (): ReportFilters => ({ search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" });
55
59
  const initialSnapshot = (): ReportWorkspaceSnapshot => ({
56
60
  loading: true,
57
61
  refreshing: false,
@@ -63,7 +67,7 @@ const initialSnapshot = (): ReportWorkspaceSnapshot => ({
63
67
  reports: [],
64
68
  presence: [],
65
69
  actionCodes: [],
66
- permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
70
+ permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
67
71
  detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
68
72
  });
69
73
 
@@ -71,6 +75,7 @@ let actorId = "";
71
75
  let rawReports: RawReport[] = [];
72
76
  let rawPresence: RawPresenceAnalyst[] = [];
73
77
  let detailRequestVersion = 0;
78
+ let listRequestVersion = 0;
74
79
  let presenceSessionId = crypto.randomUUID();
75
80
  let presenceSocket: WebSocket | null = null;
76
81
  let presenceReconnectTimer: ReturnType<typeof setTimeout> | null = null;
@@ -80,11 +85,12 @@ let realtimeRefreshTimer: ReturnType<typeof setTimeout> | null = null;
80
85
  let presenceReconnectAttempts = 0;
81
86
  let presenceRunning = false;
82
87
  let started = false;
83
-
84
- function priorityName(priority: number): "high" | "medium" | "low" {
85
- if (priority >= 70) return "high";
86
- if (priority >= 40) return "medium";
87
- return "low";
88
+ const caseMutationKeys = new Map<string, { fingerprint: string; key: string }>();
89
+ function caseMutationKey(kind: string, reportId: string, input: unknown): string {
90
+ const fingerprint = JSON.stringify([getShellSnapshot().session?.actor.id, reportId, input]);
91
+ const previous = caseMutationKeys.get(kind);
92
+ if (previous?.fingerprint === fingerprint) return previous.key;
93
+ const key = crypto.randomUUID(); caseMutationKeys.set(kind, { fingerprint, key }); return key;
88
94
  }
89
95
 
90
96
  function queues(): QueueRecord[] {
@@ -95,6 +101,10 @@ function visibleReports(filters: ReportFilters): RawReport[] {
95
101
  const query = filters.search.trim().toLowerCase();
96
102
  const now = Date.now();
97
103
  return rawReports.filter((report) => {
104
+ if (filters.state === "needs_review" && !["human_review", "human_review_requested"].includes(report.state)) return false;
105
+ const claimed = Boolean(report.assignment);
106
+ if (filters.ownership === "unassigned" && claimed) return false;
107
+ if (filters.ownership === "mine" && (!claimed || report.assignment?.reviewerId !== actorId)) return false;
98
108
  if (filters.priority && priorityName(report.priority) !== filters.priority) return false;
99
109
  const milliseconds = now - Date.parse(report.receivedAt);
100
110
  if (filters.age === "hour" && milliseconds > 3_600_000) return false;
@@ -124,12 +134,12 @@ function reportSummaries(filters: ReportFilters): ReportSummary[] {
124
134
  state: report.state,
125
135
  priority: Number(report.priority || 0),
126
136
  receivedAt: report.receivedAt,
127
- owner: report.claim ? {
128
- reviewerId: report.claim.reviewerId,
129
- mine: report.claim.reviewerId === session?.actor.id,
130
- displayName: report.claim.analyst?.displayName || "Unknown analyst",
131
- avatarUrl: report.claim.analyst?.avatarUrl || null,
132
- avatarColor: report.claim.analyst?.avatarColor || "#315d4d",
137
+ owner: report.assignment ? {
138
+ reviewerId: report.assignment.reviewerId,
139
+ mine: report.assignment.reviewerId === session?.actor.id,
140
+ displayName: report.assignment.analyst?.displayName || "Unknown analyst",
141
+ avatarUrl: report.assignment.analyst?.avatarUrl || null,
142
+ avatarColor: report.assignment.analyst?.avatarColor || "#315d4d",
133
143
  } : null,
134
144
  }));
135
145
  }
@@ -175,6 +185,8 @@ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
175
185
  ? configuration.allowed_action_codes
176
186
  : [],
177
187
  permissions: {
188
+ reassignReports: shell.session?.permissions.reassignReports === true,
189
+ manageConfiguration: shell.session?.permissions.manageConfiguration === true,
178
190
  claimReports: shell.session?.permissions.claimReports === true,
179
191
  messageReporters: shell.session?.permissions.messageReporters === true,
180
192
  decideReports: shell.session?.permissions.decideReports === true,
@@ -185,6 +197,7 @@ function publish(next?: Partial<ReportWorkspaceSnapshot>): void {
185
197
  }
186
198
 
187
199
  async function loadReports(): Promise<void> {
200
+ const version = ++listRequestVersion;
188
201
  const session = getShellSnapshot().session;
189
202
  if (!session?.permissions.readReports) {
190
203
  rawReports = [];
@@ -196,29 +209,33 @@ async function loadReports(): Promise<void> {
196
209
  publish({ error: "", refreshing: !snapshot.loading });
197
210
  const params = new URLSearchParams({ limit: "100" });
198
211
  if (snapshot.filters.queueId) params.set("queue_id", snapshot.filters.queueId);
199
- if (snapshot.filters.state) params.set("state", snapshot.filters.state);
212
+ if (snapshot.filters.state && snapshot.filters.state !== "needs_review") params.set("state", snapshot.filters.state);
200
213
  try {
201
- const response = await requestJson<ReportsResponse>(`/v1/admin/reports?${params}`);
202
- rawReports = response.reports || [];
214
+ const reports = await loadInbox<RawReport>(params);
215
+ if (version !== listRequestVersion) return;
216
+ rawReports = reports;
203
217
  setShellSnapshot((shell) => ({
204
218
  ...shell,
205
219
  reportCount: rawReports.filter((report) => !["resolved_by_human", "resolved_by_ai", "closed"].includes(report.state)).length,
206
220
  }));
207
221
  } catch (cause) {
208
- publish({ error: errorMessage(cause, "Reports could not be loaded.") });
222
+ if (version === listRequestVersion) publish({ error: errorMessage(cause, "Reports could not be loaded.") });
209
223
  } finally {
210
- publish({ loading: false, refreshing: false });
224
+ if (version === listRequestVersion) publish({ loading: false, refreshing: false });
211
225
  }
212
226
  }
213
227
 
214
- async function openReport(reportId: string): Promise<void> {
215
- const version = ++detailRequestVersion;
228
+ async function openReport(reportId: string, review = false, destination?: { section: ReportSection; taskId?: string }): Promise<void> {
216
229
  const snapshot = getReportWorkspaceSnapshot();
230
+ if (snapshot.detail.reportId && snapshot.detail.reportId !== reportId && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
231
+ const version = ++detailRequestVersion;
217
232
  document.body.classList.add("report-open");
218
233
  publish({
219
234
  detail: {
220
235
  ...snapshot.detail,
221
236
  reportId,
237
+ reviewRequest: review ? (snapshot.detail.reviewRequest || 0) + 1 : snapshot.detail.reportId === reportId ? snapshot.detail.reviewRequest : 0,
238
+ destination: destination ? { ...destination, request: version } : snapshot.detail.reportId === reportId ? snapshot.detail.destination : undefined,
222
239
  loading: true,
223
240
  error: "",
224
241
  statusMessage: snapshot.detail.reportId === reportId ? snapshot.detail.statusMessage : "",
@@ -229,12 +246,16 @@ async function openReport(reportId: string): Promise<void> {
229
246
  const encodedId = encodeURIComponent(reportId);
230
247
  const canReadRuns = getShellSnapshot().session?.permissions.readRuns === true;
231
248
  const canReadEvidence = getShellSnapshot().session?.permissions.readEvidence === true;
249
+ const prior = snapshot.detail.report?.id === reportId ? snapshot.detail.report : null;
250
+ const partialLoadErrors: string[] = [];
251
+ const unavailable = <T>(label: string, fallback: T) => () => { partialLoadErrors.push(label); return fallback; };
252
+ const readOptions = () => ({ signal: AbortSignal.timeout(15_000) });
232
253
  const [reportResponse, timelineResponse, runsResponse, actionsResponse, findingsResponse] = await Promise.all([
233
- requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`),
234
- requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`).catch(() => ({ events: [] })),
235
- canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`).catch(() => ({ runs: [] })) : Promise.resolve({ runs: [] }),
236
- requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`).catch(() => ({ actions: [] })),
237
- canReadEvidence ? requestJson<FindingsResponse>(`/v1/admin/reports/${encodedId}/findings`).catch(() => ({ findings: [] })) : Promise.resolve({ findings: [] }),
254
+ requestJson<ReportResponse>(`/v1/admin/reports/${encodedId}`, readOptions()),
255
+ requestJson<TimelineResponse>(`/v1/admin/reports/${encodedId}/timeline?limit=50`, readOptions()).catch(unavailable("activity", { events: prior?.timeline || [] })),
256
+ canReadRuns ? requestJson<WorkflowRunsResponse>(`/v1/admin/reports/${encodedId}/workflow-runs`, readOptions()).catch(unavailable("playbook progress", { runs: prior?.workflowRuns || [] })) : Promise.resolve({ runs: [] }),
257
+ requestJson<WorkflowActionsResponse>(`/v1/admin/reports/${encodedId}/actions`, readOptions()).catch(unavailable("customer action states", { actions: prior?.workflowActions || [] })),
258
+ canReadEvidence ? requestJson<FindingsResponse>(`/v1/admin/reports/${encodedId}/findings`, readOptions()).catch(unavailable("retrieved evidence", { findings: prior?.findings || [] })) : Promise.resolve({ findings: [] }),
238
259
  ]);
239
260
  if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
240
261
  publish({
@@ -242,6 +263,7 @@ async function openReport(reportId: string): Promise<void> {
242
263
  ...getReportWorkspaceSnapshot().detail,
243
264
  report: {
244
265
  ...reportResponse.report,
266
+ partialLoadErrors: partialLoadErrors.sort(),
245
267
  timeline: timelineResponse.events || [],
246
268
  workflowRuns: runsResponse.runs || [],
247
269
  workflowActions: actionsResponse.actions || [],
@@ -252,7 +274,7 @@ async function openReport(reportId: string): Promise<void> {
252
274
  sendPresenceActivity();
253
275
  } catch (cause) {
254
276
  if (version !== detailRequestVersion || getReportWorkspaceSnapshot().detail.reportId !== reportId) return;
255
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, report: null, error: errorMessage(cause, "The report could not be opened.") } });
277
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, ...(cause instanceof ApiRequestError && [401, 403, 404].includes(cause.status) ? { report: null } : {}), error: errorMessage(cause, "The case could not refresh. Use Refresh case to retry.") } });
256
278
  } finally {
257
279
  if (version === detailRequestVersion && getReportWorkspaceSnapshot().detail.reportId === reportId) {
258
280
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, loading: false } });
@@ -260,7 +282,8 @@ async function openReport(reportId: string): Promise<void> {
260
282
  }
261
283
  }
262
284
 
263
- function closeReport(): void {
285
+ function closeReport(committed = false): void {
286
+ if (!committed && !window.dispatchEvent(new Event(reportEvents.beforeClose, { cancelable: true }))) return;
264
287
  detailRequestVersion += 1;
265
288
  const messageMutationVersion = getReportWorkspaceSnapshot().detail.messageMutationVersion;
266
289
  document.body.classList.remove("report-open");
@@ -274,25 +297,39 @@ async function claimReport(): Promise<void> {
274
297
  if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
275
298
  publish({ detail: { ...snapshot.detail, error: "", pendingAction: "claim" } });
276
299
  try {
277
- await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
278
- await Promise.all([openReport(reportId), loadReports()]);
300
+ const result = await requestJson<{ claim: NonNullable<ReportDetailRecord["claim"]> }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "POST" });
301
+ const detail = getReportWorkspaceSnapshot().detail;
302
+ const analyst = { displayName: getShellSnapshot().session?.displayName || "You" };
303
+ const claim = { ...result.claim, analyst };
304
+ publish({ detail: { ...detail, report: detail.report ? { ...detail.report, claim, assignment: claim } : null,
305
+ reviewRequest: (detail.reviewRequest || 0) + 1, statusMessage: "" } });
306
+ void Promise.all([openReport(reportId), loadReports()]);
279
307
  } catch (cause) {
280
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The report could not be claimed.") } });
308
+ await openReport(reportId);
309
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Review could not be started. Your private draft is unchanged.") } });
310
+
281
311
  } finally {
282
312
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
283
313
  }
284
314
  }
285
315
 
286
- async function releaseReport(): Promise<void> {
316
+ export async function changeCaseOwner(input: { reviewerId: string | null; displayName?: string; version: number; note: string }): Promise<boolean> {
287
317
  const snapshot = getReportWorkspaceSnapshot();
288
318
  const reportId = snapshot.detail.report?.id;
289
- if (!reportId || !snapshot.permissions.claimReports || snapshot.detail.pendingAction) return;
290
- publish({ detail: { ...snapshot.detail, error: "", pendingAction: "release" } });
319
+ if (!reportId || !snapshot.permissions.reassignReports || snapshot.detail.pendingAction) return false;
320
+ publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: input.reviewerId ? "transfer" : "release" } });
291
321
  try {
292
- await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/claim`, { method: "DELETE" });
293
- await Promise.all([openReport(reportId), loadReports()]);
322
+ await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/ownership`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ reviewer_id: input.reviewerId, version: input.version, note: input.note }) });
323
+ const detail = getReportWorkspaceSnapshot().detail;
324
+ const assignment = input.reviewerId ? { reviewerId: input.reviewerId, version: input.version + 1, expiresAt: "1970-01-01T00:00:00.000Z", analyst: { displayName: input.displayName || "Assigned reviewer" } } : null;
325
+ publish({ detail: { ...detail, report: detail.report ? { ...detail.report, assignment, claim: null } : null,
326
+ statusMessage: `${input.reviewerId ? "Case transferred" : "Case returned to the queue"}. Handoff note shared; your drafts remain private.` } });
327
+ void Promise.all([openReport(reportId), loadReports()]);
328
+ return true;
294
329
  } catch (cause) {
295
- publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The claim could not be released.") } });
330
+ await openReport(reportId);
331
+ publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "Ownership could not be changed. Your note and private draft are unchanged.") } });
332
+ return false;
296
333
  } finally {
297
334
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, pendingAction: null } });
298
335
  }
@@ -302,7 +339,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
302
339
  const snapshot = getReportWorkspaceSnapshot();
303
340
  const reportId = snapshot.detail.report?.id;
304
341
  if (!reportId || !snapshot.permissions.messageReporters || snapshot.detail.pendingAction) return;
305
- const idempotencyKey = crypto.randomUUID();
342
+ const idempotencyKey = caseMutationKey("message", reportId, input);
306
343
  publish({ detail: { ...snapshot.detail, error: "", statusMessage: "", pendingAction: "message" } });
307
344
  try {
308
345
  const response = await requestJson<{ delivery_state?: string; delivery_channel?: string | null }>(`/v1/admin/reports/${encodeURIComponent(reportId)}/messages`, {
@@ -310,6 +347,7 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
310
347
  headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
311
348
  body: JSON.stringify({ idempotency_key: idempotencyKey, body: input.body, await_reporter: input.awaitReporter }),
312
349
  });
350
+ caseMutationKeys.delete("message");
313
351
  const version = getReportWorkspaceSnapshot().detail.messageMutationVersion + 1;
314
352
  const statusMessage = response.delivery_state === "queued"
315
353
  ? response.delivery_channel === "email"
@@ -317,7 +355,8 @@ async function sendMessage(input: ReporterMessageInput): Promise<void> {
317
355
  : "Participant update queued for the configured customer notification channel."
318
356
  : "The update was saved but not sent because this report has no available participant delivery channel.";
319
357
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, messageMutationVersion: version, statusMessage } });
320
- await Promise.all([openReport(reportId), loadReports()]);
358
+ // The mutation is acknowledged. Background refresh must not keep unrelated actions locked.
359
+ void Promise.all([openReport(reportId), loadReports()]);
321
360
  } catch (cause) {
322
361
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The message could not be created.") } });
323
362
  } finally {
@@ -329,7 +368,7 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
329
368
  const snapshot = getReportWorkspaceSnapshot();
330
369
  const reportId = snapshot.detail.report?.id;
331
370
  if (!reportId || !snapshot.permissions.decideReports || snapshot.detail.pendingAction) return;
332
- const idempotencyKey = crypto.randomUUID();
371
+ const idempotencyKey = caseMutationKey("decision", reportId, input);
333
372
  publish({ detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
334
373
  try {
335
374
  await requestJson(`/v1/admin/reports/${encodeURIComponent(reportId)}/decisions`, {
@@ -346,8 +385,10 @@ async function recordDecision(input: HumanDecisionInput): Promise<void> {
346
385
  ...(input.actionCode ? { action_code: input.actionCode } : {}),
347
386
  }),
348
387
  });
349
- closeReport();
350
- await loadReports();
388
+ caseMutationKeys.delete("decision");
389
+ removePrivateDraft(getShellSnapshot().session?.actor.id || "", reportId);
390
+ closeReport(true);
391
+ void loadReports();
351
392
  } catch (cause) {
352
393
  publish({ detail: { ...getReportWorkspaceSnapshot().detail, error: errorMessage(cause, "The decision could not be recorded.") } });
353
394
  } finally {
@@ -505,9 +546,10 @@ function validFilters(value: unknown): value is ReportFilters {
505
546
  const filters = value as Partial<ReportFilters>;
506
547
  return typeof filters.search === "string"
507
548
  && typeof filters.queueId === "string"
508
- && ["", "high", "medium", "low"].includes(filters.priority ?? "invalid")
549
+ && ["", "urgent", "high", "medium", "low"].includes(filters.priority ?? "invalid")
509
550
  && ["", "hour", "day", "older"].includes(filters.age ?? "invalid")
510
551
  && typeof filters.state === "string"
552
+ && [undefined, "", "unassigned", "mine"].includes(filters.ownership)
511
553
  && ["oldest", "newest", "priority"].includes(filters.sort ?? "invalid");
512
554
  }
513
555
 
@@ -521,6 +563,7 @@ export function startReportController(): void {
521
563
  publish();
522
564
  return;
523
565
  }
566
+ if (actorId && actorId !== nextActorId) clearPrivateDrafts();
524
567
  actorId = nextActorId;
525
568
  detailRequestVersion += 1;
526
569
  stopPresence();
@@ -535,9 +578,14 @@ export function startReportController(): void {
535
578
  });
536
579
  subscribeToQueueWorkspace(() => publish());
537
580
  window.addEventListener(reportEvents.open, (event) => {
538
- if (event instanceof CustomEvent && typeof event.detail?.reportId === "string") void openReport(event.detail.reportId);
581
+ if (!(event instanceof CustomEvent) || typeof event.detail?.reportId !== "string") return;
582
+ const destination = isReportSection(event.detail.section) ? {
583
+ section: event.detail.section,
584
+ ...(typeof event.detail.taskId === "string" && event.detail.taskId.length <= 100 ? { taskId: event.detail.taskId } : {}),
585
+ } : undefined;
586
+ void openReport(event.detail.reportId, event.detail.review === true, destination);
539
587
  });
540
- window.addEventListener(reportEvents.close, closeReport);
588
+ window.addEventListener(reportEvents.close, () => closeReport());
541
589
  window.addEventListener(reportEvents.refresh, () => { void loadReports(); });
542
590
  window.addEventListener(reportEvents.filters, (event) => {
543
591
  if (!(event instanceof CustomEvent) || !validFilters(event.detail?.filters)) return;
@@ -547,7 +595,6 @@ export function startReportController(): void {
547
595
  if (filters.queueId !== previous.queueId || filters.state !== previous.state) void loadReports();
548
596
  });
549
597
  window.addEventListener(reportEvents.claim, () => { void claimReport(); });
550
- window.addEventListener(reportEvents.release, () => { void releaseReport(); });
551
598
  window.addEventListener(reportEvents.message, (event) => {
552
599
  if (!(event instanceof CustomEvent) || typeof event.detail?.body !== "string" || typeof event.detail?.awaitReporter !== "boolean") return;
553
600
  void sendMessage(event.detail as ReporterMessageInput);
@@ -557,7 +604,7 @@ export function startReportController(): void {
557
604
  void recordDecision(event.detail as HumanDecisionInput);
558
605
  });
559
606
  window.addEventListener("keydown", (event) => {
560
- if (event.key === "Escape" && getReportWorkspaceSnapshot().detail.reportId) closeReport();
607
+ if (event.key === "Escape" && !event.defaultPrevented && !document.querySelector(".case-handoff-dialog[open]") && getReportWorkspaceSnapshot().detail.reportId) closeReport();
561
608
  });
562
609
  window.addEventListener("focus", () => {
563
610
  scheduleRealtimeRefresh(null, true);
@@ -1,4 +1,4 @@
1
- import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot } from "./types";
1
+ import type { HumanDecisionInput, ReportFilters, ReporterMessageInput, ReportWorkspaceSnapshot, ReportSection } from "./types";
2
2
 
3
3
  export const reportEvents = {
4
4
  state: "safest:reports:state",
@@ -6,6 +6,7 @@ export const reportEvents = {
6
6
  filters: "safest:reports:filters",
7
7
  open: "safest:reports:open",
8
8
  close: "safest:reports:close",
9
+ beforeClose: "safest:reports:before-close",
9
10
  claim: "safest:reports:claim",
10
11
  release: "safest:reports:release",
11
12
  message: "safest:reports:message",
@@ -18,7 +19,13 @@ function dispatch<TDetail>(type: string, detail?: TDetail): void {
18
19
 
19
20
  export function dispatchReportRefresh(): void { dispatch(reportEvents.refresh); }
20
21
  export function dispatchReportFilters(filters: ReportFilters): void { dispatch(reportEvents.filters, { filters }); }
21
- export function dispatchOpenReport(reportId: string): void { dispatch(reportEvents.open, { reportId }); }
22
+ export function dispatchOpenReport(reportId: string, review = false): void { dispatch(reportEvents.open, { reportId, review }); }
23
+ export function dispatchOpenReportSection(reportId: string, section: ReportSection, taskId?: string): void {
24
+ dispatch(reportEvents.open, { reportId, section, taskId });
25
+ }
26
+ export function isReportSection(value: unknown): value is ReportSection {
27
+ return typeof value === "string" && ["overview", "evidence", "review", "messages", "outcome", "activity"].includes(value);
28
+ }
22
29
  export function dispatchCloseReport(): void { dispatch(reportEvents.close); }
23
30
  export function dispatchClaimReport(): void { dispatch(reportEvents.claim); }
24
31
  export function dispatchReleaseReport(): void { dispatch(reportEvents.release); }
@@ -0,0 +1,17 @@
1
+ import { requestJson } from "../lib/http";
2
+
3
+ // The API returns at most 100 reports. Follow its opaque cursor so search,
4
+ // sorting, and summary counts include the entire authorized result set.
5
+ export async function loadInbox<T>(query = new URLSearchParams()): Promise<T[]> {
6
+ const params = new URLSearchParams(query);
7
+ params.set("limit", "100");
8
+ const reports: T[] = [];
9
+ let cursor: string | null = null;
10
+ do {
11
+ if (cursor) params.set("cursor", cursor);
12
+ const page = await requestJson<{ reports?: T[]; next_cursor?: string | null }>(`/v1/admin/reports?${params}`);
13
+ reports.push(...(page.reports || []));
14
+ cursor = page.next_cursor || null;
15
+ } while (cursor);
16
+ return reports;
17
+ }
@@ -0,0 +1,32 @@
1
+ const prefix = "safest:private-case-draft:";
2
+ export interface PrivateCaseDraft {
3
+ message: string; awaitReporter: boolean; decisionCode: string; policyCode: string;
4
+ rationale: string; reporterNotice: string; affectedNotice: string; actionCode: string; evidence: string[];
5
+ taskDecision?: string; findings?: string[];
6
+ }
7
+ const memory = new Map<string, PrivateCaseDraft>();
8
+ const key = (actorId: string, reportId: string) => `${prefix}${encodeURIComponent(actorId)}:${encodeURIComponent(reportId)}`;
9
+ export function readPrivateDraft(actorId: string, reportId: string): PrivateCaseDraft | null {
10
+ const id = key(actorId, reportId);
11
+ if (memory.has(id)) return memory.get(id)!;
12
+ try {
13
+ const stored = JSON.parse(sessionStorage.getItem(id) || "null");
14
+ if (stored && Date.now() - stored.savedAt < 86_400_000 && typeof stored.draft?.message === "string" && Array.isArray(stored.draft.evidence)) return stored.draft;
15
+ sessionStorage.removeItem(id);
16
+ } catch { /* A browser can deny storage; in-memory drafts still work. */ }
17
+ return null;
18
+ }
19
+ export function savePrivateDraft(actorId: string, reportId: string, draft: PrivateCaseDraft): void {
20
+ const id = key(actorId, reportId);
21
+ if (![draft.message,draft.rationale,draft.reporterNotice,draft.affectedNotice,draft.actionCode,draft.taskDecision].some(Boolean)) { removePrivateDraft(actorId,reportId); return; }
22
+ memory.set(id,draft);
23
+ try { sessionStorage.setItem(id,JSON.stringify({ savedAt:Date.now(),draft })); } catch { /* Keep the in-memory copy. */ }
24
+ }
25
+ export function removePrivateDraft(actorId: string, reportId: string): void {
26
+ const id = key(actorId,reportId); memory.delete(id);
27
+ try { sessionStorage.removeItem(id); } catch { /* Storage may be unavailable. */ }
28
+ }
29
+ export function clearPrivateDrafts(): void {
30
+ memory.clear();
31
+ try { Object.keys(sessionStorage).filter(id => id.startsWith(prefix)).forEach(id => sessionStorage.removeItem(id)); } catch { /* Storage may be unavailable. */ }
32
+ }
@@ -9,12 +9,12 @@ let snapshot: ReportWorkspaceSnapshot = {
9
9
  error: "",
10
10
  generatedAt: new Date().toISOString(),
11
11
  actorId: "",
12
- filters: { search: "", queueId: "", priority: "", age: "", state: "", sort: "oldest" },
12
+ filters: { search: "", queueId: "", priority: "", age: "", state: "needs_review", sort: "priority" },
13
13
  queues: [],
14
14
  reports: [],
15
15
  presence: [],
16
16
  actionCodes: [],
17
- permissions: { claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
17
+ permissions: { reassignReports: false, manageConfiguration: false, claimReports: false, messageReporters: false, decideReports: false, readRuns: false, readEvidence: false },
18
18
  detail: { reportId: null, loading: false, error: "", statusMessage: "", pendingAction: null, messageMutationVersion: 0, report: null },
19
19
  };
20
20
 
@@ -1,6 +1,6 @@
1
1
  import type { AnalystIdentityData } from "../components/AnalystIdentity";
2
2
 
3
- export type ReportPriorityFilter = "" | "high" | "medium" | "low";
3
+ export type ReportPriorityFilter = "" | "urgent" | "high" | "medium" | "low";
4
4
  export type ReportAgeFilter = "" | "hour" | "day" | "older";
5
5
  export type ReportSort = "oldest" | "newest" | "priority";
6
6
 
@@ -11,6 +11,7 @@ export interface ReportFilters {
11
11
  age: ReportAgeFilter;
12
12
  state: string;
13
13
  sort: ReportSort;
14
+ ownership?: "" | "unassigned" | "mine";
14
15
  }
15
16
 
16
17
  export interface ReportQueueOption {
@@ -51,6 +52,7 @@ export interface EvidenceReference {
51
52
  }
52
53
 
53
54
  export interface CaseMessage {
55
+ audience?: "reporter" | "affected_user" | "internal";
54
56
  id?: string;
55
57
  direction: string;
56
58
  body: string;
@@ -81,6 +83,7 @@ export interface TimelineEvent {
81
83
  }
82
84
 
83
85
  export interface FindingRecord {
86
+ runId?: string | null;
84
87
  id?: string;
85
88
  findingType: string;
86
89
  summary: string;
@@ -117,13 +120,29 @@ export interface DeliveryAction {
117
120
  }
118
121
 
119
122
  export interface DeliveryNotice {
123
+ purpose?: "appeal" | "case";
120
124
  id?: string;
125
+ messageId?: string;
126
+ decisionId?: string | null;
127
+ channel?: string | null;
128
+ attemptCount?: number;
129
+ errorCode?: string | null;
121
130
  audience: string;
122
131
  deliveryState: string;
123
132
  updatedAt: string;
124
133
  }
125
134
 
126
135
  export interface WorkflowActionRecord {
136
+ runId?: string;
137
+ payloadDigest?: string;
138
+ terminal?: boolean;
139
+ retryable?: boolean;
140
+ acceptedAt?: string | null;
141
+ confirmedAt?: string | null;
142
+ confirmationDeadlineAt?: string | null;
143
+ deliveryCycle?: number;
144
+ confirmationState?: string;
145
+ capabilityName?: string;
127
146
  id?: string;
128
147
  recordKind: string;
129
148
  actionCode: string;
@@ -154,6 +173,7 @@ export interface PolicyRule {
154
173
  }
155
174
 
156
175
  export interface ReportClaim {
176
+ version: number;
157
177
  reviewerId: string;
158
178
  expiresAt: string;
159
179
  analyst?: AnalystIdentityData;
@@ -175,6 +195,11 @@ export interface ReportAnswer {
175
195
  }
176
196
 
177
197
  export interface ReportDetailRecord {
198
+ relatedReports?: Array<{id:string;reference:string;state:string;reasonCode:string;submittedAt:string;queueName:string|null}>;
199
+ appeals?: Array<{id:string;audience:string;state:string;submittedAt:string;completionState:string|null;correctionRequired:number;correctionConfirmedAt:string|null;noticeState:string|null}>;
200
+ partialLoadErrors?: string[];
201
+ customerFacts?: Record<string, unknown>;
202
+ playbookManaged?: boolean;
178
203
  id: string;
179
204
  reference: string;
180
205
  state: string;
@@ -212,11 +237,23 @@ export interface ReportDetailRecord {
212
237
  workflowActions?: WorkflowActionRecord[];
213
238
  aiRuns?: AiRunRecord[];
214
239
  claim?: ReportClaim | null;
240
+ assignment?: ReportClaim | null;
241
+ handoffs?: Array<{ id: string; fromReviewerId: string; toReviewerId: string | null; note: string; authorName: string; createdAt: string }>;
215
242
  }
216
243
 
217
- export type ReportPendingAction = "claim" | "release" | "message" | "decision" | null;
244
+ export type ReportPendingAction = "claim" | "release" | "transfer" | "message" | "decision" | null;
245
+
246
+ export type ReportSection = "overview" | "evidence" | "review" | "messages" | "outcome" | "activity";
247
+
248
+ export interface ReportDestination {
249
+ section: ReportSection;
250
+ taskId?: string;
251
+ request: number;
252
+ }
218
253
 
219
254
  export interface ReportDetailSnapshot {
255
+ reviewRequest?: number;
256
+ destination?: ReportDestination;
220
257
  reportId: string | null;
221
258
  loading: boolean;
222
259
  error: string;
@@ -227,6 +264,8 @@ export interface ReportDetailSnapshot {
227
264
  }
228
265
 
229
266
  export interface ReportPermissions {
267
+ reassignReports: boolean;
268
+ manageConfiguration: boolean;
230
269
  claimReports: boolean;
231
270
  messageReporters: boolean;
232
271
  decideReports: boolean;
@@ -0,0 +1,13 @@
1
+ import { useEffect, useState } from "react";
2
+
3
+ // Expiry must disable inputs even when offline and no presence update arrives.
4
+ export function useEditingAccess(expiresAt?: string | null): boolean {
5
+ const [, tick] = useState(0);
6
+ const expiry = Date.parse(expiresAt || "");
7
+ useEffect(() => {
8
+ if (!Number.isFinite(expiry) || expiry <= Date.now()) return;
9
+ const timer = setTimeout(() => tick(value => value + 1), Math.min(2_147_483_647,expiry - Date.now() + 10));
10
+ return () => clearTimeout(timer);
11
+ }, [expiry]);
12
+ return Number.isFinite(expiry) && expiry > Date.now();
13
+ }
@@ -144,7 +144,7 @@ export function SettingsWorkspace() {
144
144
  const isOptional = optional.has(feature.featureKey);
145
145
  return <article className="setup-row" key={feature.featureKey}><span className={`setup-state${ready ? "" : " warning"}`}>{ready ? "✓" : "!"}</span><div><strong>{featureName(feature.featureKey)}</strong><small>{feature.message || feature.detail || (ready ? "Verified in this deployment" : isOptional ? "Optional setup" : "Verification required")}</small></div>{ready ? <span className="badge">Ready</span> : <button className="secondary compact-button" type="button" disabled={Boolean(verifying)} onClick={() => void verify(feature.featureKey)}>{verifying === feature.featureKey ? "Checking…" : isOptional ? "Check optional" : "Verify"}</button>}</article>;
146
146
  }) : <div className="settings-empty-row"><strong>No setup items</strong><small>There is nothing to verify for this deployment.</small></div>}</div>}</section>
147
- <section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <span className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></span>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
147
+ <section className="surface settings-card" aria-labelledby="workspace-details-title"><div className="surface-head"><h2 id="workspace-details-title">Workspace details</h2></div>{loading && !installation ? <SettingsLoading /> : installation ? <dl className="settings-facts">{installationFacts(installation).map(([label, value]) => <div className="settings-fact" key={label}><dt>{label}</dt><dd>{value}</dd></div>)}</dl> : <div className="settings-empty-row">Workspace details are unavailable.</div>}</section>
148
148
  </> : null}
149
149
  {canManageConfiguration ? <BrandEditor brand={brand} displayName={installation?.displayName ?? "Safest Resolve"} loading={loading && !brand} onSaved={setBrand} /> : null}
150
150
  {canManageIntegrations ? <section className="surface settings-card settings-wide" aria-labelledby="reporting-channels-title"><div className="surface-head"><div><h2 id="reporting-channels-title">Reporting channels</h2><p>Choose where people open the form and what context can be trusted.</p></div><button className="primary" type="button" onClick={openChannelDialog}>Add channel</button></div>{loading && !channels.length && !integrations.length ? <SettingsLoading /> : <div className="integration-list">{channels.map((channel) => <ChannelRow channel={channel} key={channel.id} />)}{integrations.map((integration) => <IntegrationRow integration={integration} key={integration.id} />)}{!channels.length && !integrations.length ? <div className="settings-empty-row"><strong>No reporting channels yet</strong><small>Add a hosted form or connect the widget to your application.</small></div> : null}</div>}</section> : null}