create-safest-tools 0.6.0 → 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 (172) hide show
  1. package/README.md +3 -3
  2. package/package.json +2 -2
  3. package/src/config.mjs +2 -2
  4. package/src/update.mjs +10 -1
  5. package/template/README.md +2 -2
  6. package/template/console/README.md +2 -2
  7. package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
  8. package/template/console/analytics/controller.ts +10 -2
  9. package/template/console/analytics/types.ts +7 -0
  10. package/template/console/appeals/AppealFollowup.tsx +171 -0
  11. package/template/console/appeals/AppealWorkspace.tsx +33 -4
  12. package/template/console/appeals/RecordAppealDialog.tsx +42 -0
  13. package/template/console/appeals/appeal-corrections.css +29 -0
  14. package/template/console/appeals/controller.ts +40 -14
  15. package/template/console/appeals/events.ts +1 -0
  16. package/template/console/appeals/types.ts +6 -0
  17. package/template/console/appeals/useAppealDraftGuard.ts +12 -0
  18. package/template/console/assistant/AssistantWorkspace.tsx +28 -5
  19. package/template/console/command/CommandCentre.tsx +101 -64
  20. package/template/console/command/controller.ts +80 -70
  21. package/template/console/command/events.ts +1 -2
  22. package/template/console/command/presentation.ts +29 -0
  23. package/template/console/command/store.ts +4 -4
  24. package/template/console/command/types.ts +26 -21
  25. package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
  26. package/template/console/configuration/ConfigurationDialog.tsx +28 -13
  27. package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
  28. package/template/console/configuration/types.ts +1 -1
  29. package/template/console/lib/priority.ts +13 -0
  30. package/template/console/operations/CaseEscalations.tsx +142 -0
  31. package/template/console/operations/OperationsWorkspace.tsx +2 -0
  32. package/template/console/operations/case-escalations.css +20 -0
  33. package/template/console/people/PeopleWorkspace.tsx +8 -4
  34. package/template/console/people/PersonAccessDialog.tsx +40 -0
  35. package/template/console/people/types.ts +1 -0
  36. package/template/console/profile/ProfileWorkspace.tsx +1 -1
  37. package/template/console/registry/IntegrationSetup.tsx +41 -0
  38. package/template/console/registry/IntegrationVerification.tsx +54 -0
  39. package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
  40. package/template/console/registry/RegistryDialog.tsx +56 -27
  41. package/template/console/registry/RegistryWorkspace.tsx +10 -5
  42. package/template/console/registry/integration-verification.css +25 -0
  43. package/template/console/reports/CaseActions.tsx +81 -0
  44. package/template/console/reports/CaseContinuity.tsx +27 -0
  45. package/template/console/reports/CaseNotices.tsx +48 -0
  46. package/template/console/reports/CaseOwnership.tsx +78 -0
  47. package/template/console/reports/CaseReviewTasks.tsx +148 -0
  48. package/template/console/reports/EnrichmentFindings.tsx +148 -0
  49. package/template/console/reports/InvestigationBrief.tsx +29 -0
  50. package/template/console/reports/ReportDrawer.tsx +122 -67
  51. package/template/console/reports/ReportWorkspace.tsx +112 -23
  52. package/template/console/reports/controller.ts +125 -60
  53. package/template/console/reports/events.ts +9 -2
  54. package/template/console/reports/load-inbox.ts +17 -0
  55. package/template/console/reports/presence-presentation.ts +52 -0
  56. package/template/console/reports/private-drafts.ts +32 -0
  57. package/template/console/reports/store.ts +2 -2
  58. package/template/console/reports/types.ts +50 -2
  59. package/template/console/reports/use-editing-access.ts +13 -0
  60. package/template/console/settings/SettingsWorkspace.tsx +1 -1
  61. package/template/console/shell/WorkspaceShell.tsx +55 -29
  62. package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
  63. package/template/console/shell/controller.ts +23 -16
  64. package/template/console/shell/navigation.ts +30 -39
  65. package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
  66. package/template/console/workflows/PlaybookExamples.tsx +160 -0
  67. package/template/console/workflows/PlaybookFields.tsx +109 -0
  68. package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
  69. package/template/console/workflows/PlaybookHistory.tsx +106 -0
  70. package/template/console/workflows/WorkflowDialogs.tsx +2 -1
  71. package/template/console/workflows/WorkflowStudio.tsx +108 -39
  72. package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
  73. package/template/console/workflows/templates.ts +3 -3
  74. package/template/console/workflows/types.ts +11 -0
  75. package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
  76. package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
  77. package/template/migrations/0003_action_confirmation.sql +24 -0
  78. package/template/migrations/0004_notice_recovery.sql +22 -0
  79. package/template/migrations/0005_appeal_continuity.sql +17 -0
  80. package/template/migrations/0006_case_ownership.sql +23 -0
  81. package/template/migrations/0007_integration_verification.sql +21 -0
  82. package/template/migrations/0008_case_escalations.sql +135 -0
  83. package/template/migrations/0009_participant_continuation.sql +61 -0
  84. package/template/migrations/0010_appeal_corrections.sql +41 -0
  85. package/template/npm-shrinkwrap.json +8036 -0
  86. package/template/package.json +31 -22
  87. package/template/public/_headers +16 -1
  88. package/template/public/console/auth-shell.js +22151 -14847
  89. package/template/public/console/release.json +1 -0
  90. package/template/public/console/workspace-features.css +2 -0
  91. package/template/public/embed/embed.css +3 -0
  92. package/template/public/embed/embed.js +19 -1
  93. package/template/public/embed/index.html +2 -0
  94. package/template/public/index.html +9 -6
  95. package/template/public/participant.css +2 -0
  96. package/template/public/participant.html +13 -0
  97. package/template/public/participant.js +111 -0
  98. package/template/public/public-report.js +21 -1
  99. package/template/public/report/index.html +1 -1
  100. package/template/public/styles.css +599 -83
  101. package/template/public/workspace.css +124 -0
  102. package/template/scripts/reports-plan.mjs +1 -1
  103. package/template/shared/case-escalations.ts +31 -0
  104. package/template/shared/integration-progress.ts +27 -0
  105. package/template/shared/notice-inputs.ts +3 -0
  106. package/template/shared/playbook-resources.ts +14 -0
  107. package/template/shared/playbooks.ts +249 -0
  108. package/template/src/agent-executor.ts +25 -8
  109. package/template/src/ai-observability.ts +14 -13
  110. package/template/src/ai-registry-validation.ts +1 -1
  111. package/template/src/appeal-continuity.ts +75 -0
  112. package/template/src/appeal-correction-state.ts +46 -0
  113. package/template/src/appeal-corrections.ts +112 -0
  114. package/template/src/appeal-decisions.ts +85 -0
  115. package/template/src/assistant.ts +3 -4
  116. package/template/src/case-escalations.ts +223 -0
  117. package/template/src/case-investigator.ts +179 -0
  118. package/template/src/component-executor.ts +5 -4
  119. package/template/src/configuration-registry.ts +4 -1
  120. package/template/src/connection-oauth.ts +12 -3
  121. package/template/src/database-readiness.ts +14 -0
  122. package/template/src/human-tasks.ts +152 -55
  123. package/template/src/index.ts +191 -29
  124. package/template/src/installation-admin.ts +2 -1
  125. package/template/src/integration-progress.ts +45 -0
  126. package/template/src/integration-verification.ts +249 -0
  127. package/template/src/investigation-evidence.ts +95 -0
  128. package/template/src/investigator-fixture.ts +30 -0
  129. package/template/src/notice-delivery.ts +19 -0
  130. package/template/src/notice-recovery.ts +81 -0
  131. package/template/src/operator-scope.ts +31 -0
  132. package/template/src/participant-access.ts +163 -0
  133. package/template/src/participant-case.ts +87 -0
  134. package/template/src/participant-customer-access.ts +93 -0
  135. package/template/src/participant-recovery.ts +43 -0
  136. package/template/src/participant-routes.ts +67 -0
  137. package/template/src/platform-registry-validation.ts +25 -2
  138. package/template/src/platform-registry.ts +10 -7
  139. package/template/src/playbook-resources.ts +58 -0
  140. package/template/src/report-admin.ts +9 -2
  141. package/template/src/report-auth.ts +1 -28
  142. package/template/src/report-better-auth.ts +18 -6
  143. package/template/src/report-delivery.ts +149 -148
  144. package/template/src/report-email-inbound.ts +33 -22
  145. package/template/src/report-email-template.ts +5 -2
  146. package/template/src/report-email.ts +7 -30
  147. package/template/src/report-governance.ts +42 -45
  148. package/template/src/report-http.ts +37 -2
  149. package/template/src/report-operations.ts +30 -67
  150. package/template/src/report-operator-accounts.ts +7 -6
  151. package/template/src/report-ownership.ts +64 -0
  152. package/template/src/report-presence.ts +15 -9
  153. package/template/src/report-repository.ts +16 -7
  154. package/template/src/report-turnstile.ts +7 -3
  155. package/template/src/report-types.ts +1 -0
  156. package/template/src/report-workflow.ts +183 -249
  157. package/template/src/workflow-actions.ts +248 -156
  158. package/template/src/workflow-compiler.ts +63 -12
  159. package/template/src/workflow-dynamic-runtime.ts +20 -9
  160. package/template/src/workflow-effects.ts +117 -39
  161. package/template/src/workflow-event-transport.ts +16 -0
  162. package/template/src/workflow-expressions.ts +30 -0
  163. package/template/src/workflow-loader-code.ts +21 -0
  164. package/template/src/workflow-platform-types.ts +3 -0
  165. package/template/src/workflow-platform-validation.ts +23 -1
  166. package/template/src/workflow-repository.ts +131 -28
  167. package/template/src/workflow-runs.ts +61 -13
  168. package/template/src/workflow-simulator.ts +7 -4
  169. package/template/src/workspace-governance.ts +6 -4
  170. package/template/tsconfig.json +1 -0
  171. package/template/vite.console.config.ts +30 -2
  172. package/template/worker-configuration.d.ts +3 -2
@@ -4,6 +4,12 @@ import { PageHeader } from "../components/PageHeader";
4
4
  import { dispatchAppealDecision, dispatchAppealRefresh, dispatchClaimAppeal, dispatchCloseAppeal, dispatchOpenAppeal } from "./events";
5
5
  import { getAppealWorkspaceSnapshot, subscribeToAppealWorkspace } from "./store";
6
6
  import type { AppealOutcome, AppealRecord } from "./types";
7
+ import { RecordAppealDialog } from "./RecordAppealDialog";
8
+ import { dispatchOpenReport } from "../reports/events";
9
+ import { getAppealWorkspaceSnapshot as getCurrentAppeals } from "./store";
10
+ import { useAppealDraftGuard } from "./useAppealDraftGuard";
11
+ import { AppealFollowup } from "./AppealFollowup";
12
+ import { getShellSnapshot, subscribeToShell } from "../shell/store";
7
13
 
8
14
  function AppealRow({ appeal }: { appeal: AppealRecord }) {
9
15
  return (
@@ -17,23 +23,25 @@ function AppealRow({ appeal }: { appeal: AppealRecord }) {
17
23
  }
18
24
 
19
25
  export function AppealWorkspace() {
26
+ const [recording, setRecording] = useState(false);
20
27
  const snapshot = useSyncExternalStore(subscribeToAppealWorkspace, getAppealWorkspaceSnapshot, getAppealWorkspaceSnapshot);
21
28
  return (
22
29
  <div className="page-stack" data-react-slice="appeal-workspace">
23
30
  <PageHeader titleId="appeals-title" title="Appeals" description="Review contested decisions with a separate human owner.">
24
- <div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canReview} onClick={dispatchAppealRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
31
+ <div className="surface-buttons">{snapshot.canReview ? <button className="primary" type="button" onClick={() => setRecording(true)}>Record participant appeal</button> : null}<button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canReview} onClick={dispatchAppealRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
25
32
  </PageHeader>
26
33
  <section className="surface appeals-surface" aria-labelledby="appeals-title">
27
34
  <div className="appeal-list" aria-busy={snapshot.loading || snapshot.refreshing}>
28
35
  {snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading appeals" /> : snapshot.appeals.length ? snapshot.appeals.map((appeal) => <AppealRow appeal={appeal} key={appeal.id} />) : <div className="empty"><span aria-hidden="true">✓</span><h3>No appeals in this view</h3><p>New appeals will appear here when a participant contests a decision.</p></div>}
29
36
  </div>
30
37
  <p className="error surface-error" role="alert">{snapshot.error}</p>
31
- </section>
38
+ </section><RecordAppealDialog open={recording} onClose={() => setRecording(false)} />
32
39
  </div>
33
40
  );
34
41
  }
35
42
 
36
43
  export function AppealDialog() {
44
+ const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
37
45
  const snapshot = useSyncExternalStore(subscribeToAppealWorkspace, getAppealWorkspaceSnapshot, getAppealWorkspaceSnapshot);
38
46
  const dialogRef = useRef<HTMLDialogElement>(null);
39
47
  const rationaleRef = useRef<HTMLTextAreaElement>(null);
@@ -43,7 +51,15 @@ export function AppealDialog() {
43
51
  const appeal = snapshot.detail.appeal;
44
52
  const activeClaim = Boolean(appeal?.reviewerId && appeal.claimExpiresAt && Date.parse(appeal.claimExpiresAt) > Date.parse(snapshot.generatedAt));
45
53
  const mine = Boolean(activeClaim && appeal?.reviewerId === snapshot.actorId);
46
- const disabled = !mine || snapshot.detail.pendingAction === "decision";
54
+ const complete = Boolean(appeal && !["submitted", "human_review"].includes(appeal.state));
55
+ const disabled = complete || !mine || !shell.session?.permissions.decideAppeals || Boolean(snapshot.detail.pendingAction);
56
+ useAppealDraftGuard(Boolean(snapshot.detail.appealId), !complete && Boolean(rationale || participantNotice || outcome !== "upheld"), Boolean(snapshot.detail.pendingAction));
57
+ useEffect(()=>{
58
+ if(!snapshot.detail.appealId)return;
59
+ const timer=window.setInterval(()=>{if(!document.hidden&&!getCurrentAppeals().detail.pendingAction)dispatchAppealRefresh();},10000);
60
+ return()=>window.clearInterval(timer);
61
+ },[snapshot.detail.appealId]);
62
+ const openOriginal=()=>{dispatchCloseAppeal();if(!getCurrentAppeals().detail.appealId&&appeal?.reportId)dispatchOpenReport(appeal.reportId);};
47
63
 
48
64
  useEffect(() => {
49
65
  const dialog = dialogRef.current;
@@ -66,7 +82,20 @@ export function AppealDialog() {
66
82
  return (
67
83
  <dialog ref={dialogRef} className="detail-dialog appeal-dialog" aria-labelledby="appeal-reference" onCancel={(event) => { event.preventDefault(); dispatchCloseAppeal(); }} data-react-slice="appeal-dialog">
68
84
  <div className="detail-head"><div><h2 id="appeal-reference">{appeal ? `${appeal.reportReference} appeal` : "Appeal"}</h2><p>{appeal ? readable(appeal.state) : "Loading appeal…"}</p></div><button className="icon-button" type="button" aria-label="Close appeal" onClick={dispatchCloseAppeal}>×</button></div>
69
- {appeal ? <><p>{appeal.reason}</p><div className="claim-bar"><p>{mine ? "This appeal is claimed by you." : activeClaim ? "Another reviewer currently owns this appeal." : "Appeals require a separate human reviewer."}</p>{!activeClaim && snapshot.canReview ? <button className="primary" type="button" disabled={snapshot.detail.pendingAction === "claim"} onClick={dispatchClaimAppeal}>{snapshot.detail.pendingAction === "claim" ? "Claiming…" : "Claim appeal"}</button> : null}</div><form onSubmit={submit}><label>Outcome<select value={outcome} disabled={disabled} onChange={(event) => setOutcome(event.currentTarget.value as AppealOutcome)}><option value="upheld">Uphold</option><option value="modified">Modify</option><option value="reversed">Reverse</option></select></label><label>Internal rationale<textarea ref={rationaleRef} maxLength={8000} required disabled={disabled} value={rationale} onChange={(event) => setRationale(event.currentTarget.value)} /></label><label>Participant notice<textarea maxLength={4000} required disabled={disabled} value={participantNotice} onChange={(event) => setParticipantNotice(event.currentTarget.value)} /></label><button className="primary" type="submit" disabled={disabled}>{snapshot.detail.pendingAction === "decision" ? "Recording…" : "Record appeal decision"}</button></form></> : null}
85
+ {appeal ? <>
86
+ <section className="case-guidance"><h3>Participant’s challenge</h3><p>{appeal.reason}</p></section>
87
+ {appeal.originalDecisionCode ? <section className="surface"><h3>Original decision</h3><p>{readable(appeal.originalDecisionCode)} · {appeal.originalPolicyCode}</p><p>{appeal.originalRationale}</p>{appeal.reportId ? <button type="button" className="secondary" onClick={openOriginal}>Open original report</button> : null}</section> : null}
88
+ {complete ? <AppealFollowup key={appeal.id} appeal={appeal} actorId={shell.session?.permissions.decideAppeals?snapshot.actorId:""} /> : <>
89
+ <div className="claim-bar"><p>{mine ? "This appeal is claimed by you." : activeClaim ? "Another reviewer currently owns this appeal." : "Appeals require a separate human reviewer."}</p>{!activeClaim && snapshot.canReview ? <button className="primary" type="button" disabled={snapshot.detail.pendingAction === "claim"} onClick={dispatchClaimAppeal}>{snapshot.detail.pendingAction === "claim" ? "Claiming…" : "Claim appeal"}</button> : null}</div>
90
+ <form onSubmit={submit}><label>Outcome<select value={outcome} disabled={disabled} onChange={event=>setOutcome(event.currentTarget.value as AppealOutcome)}><option value="upheld">Uphold</option><option value="modified">Modify</option><option value="reversed">Reverse</option></select></label>
91
+ {outcome!=="upheld"?<p>A changed verdict may require a correction in the customer application. The original action remains recorded; verify any correction in the follow-up after submitting.</p>:null}
92
+ <label>Internal rationale<textarea ref={rationaleRef} maxLength={8000} required disabled={disabled} value={rationale} onChange={event=>setRationale(event.currentTarget.value)} /></label>
93
+ <label>Participant notice<textarea maxLength={4000} required disabled={disabled} value={participantNotice} onChange={event=>setParticipantNotice(event.currentTarget.value)} /></label>
94
+ <p>This notice is sent only to the participant who appealed. Keep other participants’ identities and internal notes out of it.</p>
95
+ <button className="primary" type="submit" disabled={disabled}>{snapshot.detail.pendingAction === "decision" ? "Recording…" : "Record appeal decision"}</button>
96
+ </form>
97
+ </>}
98
+ </> : null}
70
99
  <p className="error" role="alert">{snapshot.detail.error}</p>
71
100
  </dialog>
72
101
  );
@@ -0,0 +1,42 @@
1
+ import { useEffect, useId, useRef, useState, type FormEvent } from "react";
2
+ import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
3
+ import { dispatchReportRefresh } from "../reports/events";
4
+ import { dispatchAppealRefresh } from "./events";
5
+
6
+ export function RecordAppealDialog({ open, onClose, reportReference = "", onRecorded }: { open: boolean; onClose: () => void; reportReference?: string; onRecorded?: () => void }) {
7
+ const titleId = useId();
8
+ const ref = useRef<HTMLDialogElement>(null);
9
+ const key = useRef(crypto.randomUUID());
10
+ const [reference, setReference] = useState("");
11
+ const [audience, setAudience] = useState("reporter");
12
+ const [reason, setReason] = useState("");
13
+ const [pending, setPending] = useState(false);
14
+ const [error, setError] = useState("");
15
+ useEffect(() => {
16
+ if (open) { key.current = crypto.randomUUID(); setReference(reportReference); setReason(""); setAudience("reporter"); setError(""); ref.current?.showModal(); }
17
+ else ref.current?.close();
18
+ }, [open, reportReference]);
19
+ const dirty = Boolean(reason || audience !== "reporter" || reference !== reportReference);
20
+ const close = () => { if (!pending && (!dirty || window.confirm("Discard this unsent appeal?"))) onClose(); };
21
+ useEffect(() => {
22
+ const unload = (event: BeforeUnloadEvent) => { if (open && (dirty || pending)) { event.preventDefault(); event.returnValue = ""; } };
23
+ window.addEventListener("beforeunload",unload); return () => window.removeEventListener("beforeunload",unload);
24
+ },[open,dirty,pending]);
25
+ const submit = async (event: FormEvent<HTMLFormElement>) => {
26
+ event.preventDefault(); if (pending) return; setPending(true); setError("");
27
+ try {
28
+ await requestJson("/v1/admin/appeals", { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": key.current }, body: JSON.stringify({ report_reference: reference, audience, reason }) });
29
+ dispatchAppealRefresh(); dispatchReportRefresh(); onRecorded?.(); onClose();
30
+ } catch (cause) { setError(errorMessage(cause, "The appeal could not be recorded.")); }
31
+ finally { setPending(false); }
32
+ };
33
+ return <dialog ref={ref} className="editor-dialog" aria-labelledby={titleId} onKeyDown={event=>event.stopPropagation()} onCancel={(event) => { event.preventDefault(); event.stopPropagation(); close(); }}>
34
+ <div className="detail-head"><div><h2 id={titleId}>Record participant appeal</h2><p>Record a contest received from a participant by email or your support channel. A separate reviewer will assess the original decision.</p></div><button type="button" className="icon-button" disabled={pending} aria-label="Close appeal form" onClick={close}>×</button></div>
35
+ <form className="editor-form" onSubmit={(event) => void submit(event)}>
36
+ <label>Report reference<input required readOnly={Boolean(reportReference)} maxLength={100} placeholder="RPT-…" disabled={pending} value={reference} onChange={(event) => setReference(event.currentTarget.value)} /></label>
37
+ <label>Participant<select value={audience} disabled={pending} onChange={(event) => setAudience(event.currentTarget.value)}><option value="reporter">Reporter</option><option value="affected_user">Affected user</option></select></label>
38
+ <label>Reason for appeal<textarea required minLength={3} maxLength={8000} disabled={pending} value={reason} onChange={(event) => setReason(event.currentTarget.value)} /></label>
39
+ <p className="error" role="alert">{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 ? "Recording…" : "Record appeal"}</button></div>
40
+ </form>
41
+ </dialog>;
42
+ }
@@ -0,0 +1,29 @@
1
+ .appeal-dialog > .detail-head { background: var(--paper, #fff); }
2
+ .appeal-correction-card { border: 1px solid var(--line, #d8dfdc); border-radius: 12px; padding: 16px; margin-block: 16px; min-width: 0; }
3
+ .appeal-correction-form { display: grid; gap: 16px; min-width: 0; }
4
+ .appeal-correction-form label:not(.appeal-correction-approval) { display: grid; gap: 6px; font-size: .875rem; }
5
+ .appeal-correction-form input:not([type="checkbox"]), .appeal-correction-form select, .appeal-correction-form textarea { width: 100%; min-width: 0; max-width: 100%; box-sizing: border-box; font-size: 1rem; min-height: 44px; }
6
+ .appeal-correction-form textarea { min-height: 96px; }
7
+ .appeal-correction-approval { display: flex; gap: 10px; align-items: flex-start; font-weight: normal; }
8
+ .appeal-correction-approval input { width: 20px; height: 20px; flex: 0 0 20px; margin-top: 2px; }
9
+ .appeal-correction-card dd, .appeal-operation-reference { overflow-wrap: anywhere; }
10
+ .appeal-operation-reference { font-size: 0.875rem; }
11
+ .appeal-correction-progress h5 { font-size: 1rem; margin-block: 16px 8px; }
12
+ @media (max-width: 600px) { .appeal-correction-card { padding: 12px; } .appeal-correction-form button { width: 100%; min-height: 44px; white-space: normal; } }
13
+
14
+ /* Target identifiers need the full card width. A shared two-column dl otherwise
15
+ lets a long URL consume one column and squeezes the owner into a few letters. */
16
+ .appeal-correction-card .appeal-target-summary { min-width: 0; margin-block: 16px; }
17
+ .appeal-correction-card .appeal-target-fields { display: block; margin: 0; }
18
+ .appeal-correction-card .appeal-target-fields > div { display: block; min-width: 0; margin-block: 0 12px; }
19
+ .appeal-correction-card .appeal-target-fields dt { display: block; float: none; font-size: .8125rem; color: var(--muted, #5c6862); margin: 0 0 4px; }
20
+ .appeal-correction-card .appeal-target-fields dd { display: block; margin: 0; min-width: 0; max-width: 100%; font-size: .9375rem; line-height: 1.5; overflow-wrap: anywhere; word-break: normal; }
21
+ .appeal-target-link { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; margin-block: 12px; }
22
+ .appeal-target-link a { display: inline-flex; align-items: center; min-height: 44px; }
23
+ .appeal-target-link > span { color: var(--muted, #5c6862); font-size: .8125rem; overflow-wrap: anywhere; min-width: 0; }
24
+ .appeal-target-facts summary, .appeal-operation-details summary { display: list-item; min-height: 44px; padding-block: 10px; margin-inline-start: 1.1em; box-sizing: border-box; cursor: pointer; }
25
+ .appeal-target-facts[open] summary { margin-bottom: 8px; }
26
+
27
+ .appeal-correction-action { margin-top: 20px; font-size: 1rem; }
28
+ .appeal-operation-details { margin-block: 8px; font-size: .875rem; }
29
+ .appeal-operation-details p { margin-block: 4px 12px; }
@@ -1,4 +1,4 @@
1
- import { errorMessage, requestJson } from "../lib/http";
1
+ import { ApiRequestError, errorMessage, requestJson } from "../lib/http";
2
2
  import { dispatchReportRefresh } from "../reports/events";
3
3
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
4
4
  import { appealEvents } from "./events";
@@ -19,7 +19,11 @@ const initialSnapshot = (): AppealWorkspaceSnapshot => ({
19
19
  });
20
20
 
21
21
  let actorId = "";
22
+ let accessSignature = "";
22
23
  let started = false;
24
+ let listVersion = 0;
25
+ let detailVersion = 0;
26
+ let decisionRetry: {appealId:string;actorId:string;key:string} | null = null;
23
27
 
24
28
  function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot {
25
29
  const session = getShellSnapshot().session;
@@ -27,6 +31,7 @@ function permissions(snapshot: AppealWorkspaceSnapshot): AppealWorkspaceSnapshot
27
31
  }
28
32
 
29
33
  async function loadAppeals(): Promise<void> {
34
+ const version = ++listVersion;
30
35
  const session = getShellSnapshot().session;
31
36
  if (!session?.permissions.reviewAppeals) {
32
37
  setAppealWorkspaceSnapshot(permissions({ ...initialSnapshot(), loading: false }));
@@ -36,26 +41,39 @@ async function loadAppeals(): Promise<void> {
36
41
  setAppealWorkspaceSnapshot(permissions({ ...current, error: "", refreshing: !current.loading }));
37
42
  try {
38
43
  const result = await requestJson<AppealListResponse>("/v1/admin/appeals?limit=100");
44
+ if (version !== listVersion || session.actor.id !== getShellSnapshot().session?.actor.id) return;
39
45
  setAppealWorkspaceSnapshot((snapshot) => {
40
46
  const appeals = result.appeals || [];
41
47
  const selected = snapshot.detail.appealId ? appeals.find((appeal) => appeal.id === snapshot.detail.appealId) || null : null;
42
- return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: selected } : snapshot.detail });
48
+ return permissions({ ...snapshot, appeals, detail: selected ? { ...snapshot.detail, appeal: {...snapshot.detail.appeal,...selected} } : snapshot.detail });
43
49
  });
44
50
  } catch (cause) {
45
- setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
51
+ if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "Appeals could not be loaded.") }));
46
52
  } finally {
47
- setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
53
+ if (version === listVersion) setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
48
54
  }
49
55
  }
50
56
 
51
- function openAppeal(appealId: string): void {
57
+ async function openAppeal(appealId: string, refresh = false): Promise<void> {
58
+ const current=getAppealWorkspaceSnapshot();
59
+ if (!current.canReview) return;
60
+ if (current.detail.appealId && current.detail.appealId !== appealId && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
61
+ const version=++detailVersion;
52
62
  setAppealWorkspaceSnapshot((snapshot) => {
53
63
  const appeal = snapshot.appeals.find((candidate) => candidate.id === appealId) || null;
54
- return appeal ? permissions({ ...snapshot, detail: { appealId, appeal, error: "", pendingAction: null } }) : snapshot;
64
+ return refresh ? snapshot : permissions({ ...snapshot, detail: { appealId, appeal: snapshot.detail.appealId===appealId?snapshot.detail.appeal:appeal, error: "", pendingAction: null } });
55
65
  });
66
+ try {
67
+ const result = await requestJson<{ appeal: AppealRecord }>(`/v1/admin/appeals/${encodeURIComponent(appealId)}`);
68
+ if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, appeal: result.appeal } } : snapshot);
69
+ } catch (cause) {
70
+ if(version===detailVersion) setAppealWorkspaceSnapshot((snapshot) => snapshot.detail.appealId === appealId ? { ...snapshot, detail: { ...snapshot.detail, ...(cause instanceof ApiRequestError && [401,403,404].includes(cause.status)?{appeal:null}:{}), error: errorMessage(cause, "Appeal details could not be loaded.") } } : snapshot);
71
+ }
56
72
  }
57
73
 
58
- function closeAppeal(): void {
74
+ function closeAppeal(committed = false): void {
75
+ if (!committed && !window.dispatchEvent(new Event(appealEvents.beforeClose,{cancelable:true}))) return;
76
+ detailVersion++;
59
77
  setAppealWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { appealId: null, appeal: null, error: "", pendingAction: null } }));
60
78
  }
61
79
 
@@ -67,6 +85,7 @@ async function claimAppeal(): Promise<void> {
67
85
  try {
68
86
  await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/claim`, { method: "POST" });
69
87
  await loadAppeals();
88
+ await openAppeal(appeal.id);
70
89
  } catch (cause) {
71
90
  setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal could not be claimed.") } }));
72
91
  } finally {
@@ -78,8 +97,9 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
78
97
  const snapshot = getAppealWorkspaceSnapshot();
79
98
  const appeal = snapshot.detail.appeal;
80
99
  const activeClaim = Boolean(appeal && appeal.reviewerId === snapshot.actorId && appeal.claimExpiresAt && Date.parse(appeal.claimExpiresAt) > Date.now());
81
- if (!appeal || !snapshot.canReview || snapshot.detail.pendingAction || !activeClaim) return;
82
- const idempotencyKey = crypto.randomUUID();
100
+ if (!appeal || !getShellSnapshot().session?.permissions.decideAppeals || snapshot.detail.pendingAction || !activeClaim) return;
101
+ if (!decisionRetry || decisionRetry.appealId!==appeal.id || decisionRetry.actorId!==snapshot.actorId) decisionRetry={appealId:appeal.id,actorId:snapshot.actorId,key:crypto.randomUUID()};
102
+ const idempotencyKey = decisionRetry.key;
83
103
  setAppealWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: "decision" } });
84
104
  try {
85
105
  await requestJson(`/v1/admin/appeals/${encodeURIComponent(appeal.id)}/decision`, {
@@ -87,10 +107,13 @@ async function recordDecision(input: AppealDecisionInput): Promise<void> {
87
107
  headers: { "content-type": "application/json", "idempotency-key": idempotencyKey },
88
108
  body: JSON.stringify({ idempotency_key: idempotencyKey, outcome: input.outcome, rationale: input.rationale, participant_notice: input.participantNotice }),
89
109
  });
90
- closeAppeal();
110
+ decisionRetry=null;
111
+ if (getAppealWorkspaceSnapshot().detail.appealId!==appeal.id || getShellSnapshot().session?.actor.id!==snapshot.actorId) return;
112
+ closeAppeal(true);
91
113
  await loadAppeals();
92
114
  dispatchReportRefresh();
93
115
  } catch (cause) {
116
+ if (cause instanceof ApiRequestError && cause.status>=400 && cause.status<500) decisionRetry=null;
94
117
  setAppealWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The appeal decision could not be recorded.") } }));
95
118
  } finally {
96
119
  setAppealWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
@@ -103,19 +126,22 @@ export function startAppealController(): void {
103
126
  subscribeToShell(() => {
104
127
  const session = getShellSnapshot().session;
105
128
  const nextActorId = session?.actor.id || "";
106
- if (nextActorId === actorId) {
129
+ const nextAccessSignature = JSON.stringify({permissions:session?.permissions,queueScope:session?.queueScope});
130
+ if (nextActorId === actorId && nextAccessSignature === accessSignature) {
107
131
  setAppealWorkspaceSnapshot((snapshot) => permissions(snapshot));
108
132
  return;
109
133
  }
110
134
  actorId = nextActorId;
135
+ accessSignature = nextAccessSignature;
136
+ listVersion++; detailVersion++; decisionRetry=null;
111
137
  setAppealWorkspaceSnapshot(initialSnapshot());
112
138
  if (session) void loadAppeals();
113
139
  });
114
- window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); });
140
+ window.addEventListener(appealEvents.refresh, () => { void loadAppeals(); const id=getAppealWorkspaceSnapshot().detail.appealId; if(id)void openAppeal(id,true); });
115
141
  window.addEventListener(appealEvents.open, (event) => {
116
- if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") openAppeal(event.detail.appealId);
142
+ if (event instanceof CustomEvent && typeof event.detail?.appealId === "string") void openAppeal(event.detail.appealId);
117
143
  });
118
- window.addEventListener(appealEvents.close, closeAppeal);
144
+ window.addEventListener(appealEvents.close, () => closeAppeal());
119
145
  window.addEventListener(appealEvents.claim, () => { void claimAppeal(); });
120
146
  window.addEventListener(appealEvents.decision, (event) => {
121
147
  if (!(event instanceof CustomEvent) || !event.detail || !["upheld", "modified", "reversed"].includes(event.detail.outcome)) return;
@@ -5,6 +5,7 @@ export const appealEvents = {
5
5
  refresh: "safest:appeals:refresh",
6
6
  open: "safest:appeals:open",
7
7
  close: "safest:appeals:close",
8
+ beforeClose: "safest:appeals:before-close",
8
9
  claim: "safest:appeals:claim",
9
10
  decision: "safest:appeals:decision",
10
11
  } as const;
@@ -3,6 +3,12 @@ export type AppealPendingAction = "claim" | "decision" | null;
3
3
 
4
4
  export interface AppealRecord {
5
5
  id: string;
6
+ reportId?: string;
7
+ originalDecisionCode?: string;
8
+ originalPolicyCode?: string;
9
+ originalRationale?: string;
10
+ originalMakerId?: string;
11
+ decisions?: Array<{ id: string; outcome: string; rationale: string; reviewerId: string; completionState: string; correctionRequired: number; correctionReference: string | null; correctionNote: string | null; correctionConfirmedAt: string | null; correctionConfirmedBy: string | null; noticeState: string | null }>;
6
12
  reportReference: string;
7
13
  appellantAudience: string;
8
14
  submittedAt: string;
@@ -0,0 +1,12 @@
1
+ import { useEffect } from "react";
2
+ import { appealEvents } from "./events";
3
+
4
+ export function useAppealDraftGuard(open:boolean,dirty:boolean,pending:boolean) {
5
+ useEffect(()=>{
6
+ if(!open)return;
7
+ const close=(event:Event)=>{if(pending || (dirty&&!window.confirm("Discard your unsent appeal changes?")))event.preventDefault();};
8
+ const unload=(event:BeforeUnloadEvent)=>{if(dirty||pending){event.preventDefault();event.returnValue="";}};
9
+ window.addEventListener(appealEvents.beforeClose,close); window.addEventListener("beforeunload",unload);
10
+ return()=>{window.removeEventListener(appealEvents.beforeClose,close);window.removeEventListener("beforeunload",unload);};
11
+ },[open,dirty,pending]);
12
+ }
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type KeyboardEvent } from "react";
2
2
  import { PageHeader } from "../components/PageHeader";
3
3
  import { relativeTime } from "../lib/format";
4
- import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
4
+ import { ApiRequestError, errorMessage, mutationHeaders, requestJson } from "../lib/http";
5
5
  import { dispatchOpenReport } from "../reports/events";
6
6
  import { dispatchNavigation } from "../shell/events";
7
7
  import { getShellSnapshot, subscribeToShell } from "../shell/store";
@@ -9,7 +9,7 @@ import type { AssistantHistory, AssistantMessage, AssistantSessionResponse } fro
9
9
 
10
10
  const prompts = [
11
11
  "How many phishing reports were resolved yesterday?",
12
- "List unresolved workflow failures and show their current state.",
12
+ "Find spam",
13
13
  ] as const;
14
14
  const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
15
15
  const readable = (value: string): string => value.replaceAll("_", " ").replace(/^./u, (letter) => letter.toUpperCase());
@@ -28,14 +28,33 @@ export function AssistantWorkspace() {
28
28
  const [error, setError] = useState("");
29
29
  const inputRef = useRef<HTMLTextAreaElement>(null);
30
30
  const messagesRef = useRef<HTMLDivElement>(null);
31
+ const sendingRef = useRef(false);
31
32
  const canUse = shell.session?.permissions.useAssistant === true;
32
33
 
33
34
  useEffect(() => {
35
+ let current = true;
34
36
  setSessionId("");
35
37
  setHistory(null);
36
38
  setDraft("");
37
39
  setError("");
38
- }, [shell.session?.actor.id]);
40
+ setPending("");
41
+ const actor = shell.session?.actor.id;
42
+ if (!actor || !canUse) return;
43
+ const key = `safest-assistant:${actor}`;
44
+ let saved = "";
45
+ try { saved = sessionStorage.getItem(key) || ""; } catch { /* Browser storage may be disabled. */ }
46
+ if (!saved) return;
47
+ setPending("new");
48
+ void requestJson<AssistantHistory>(`/v1/admin/assistant/sessions/${encodeURIComponent(saved)}`).then((result) => {
49
+ if (current) { setSessionId(saved); setHistory(result); }
50
+ }).catch((cause) => {
51
+ if (!current) return;
52
+ if (cause instanceof ApiRequestError && [404, 409].includes(cause.status)) {
53
+ try { sessionStorage.removeItem(key); } catch { /* Storage is optional. */ }
54
+ } else setError(errorMessage(cause, "Your conversation could not be restored. Refresh to try again."));
55
+ }).finally(() => { if (current) setPending(""); });
56
+ return () => { current = false; };
57
+ }, [shell.session?.actor.id, canUse]);
39
58
 
40
59
  useEffect(() => {
41
60
  const focus = (): void => { requestAnimationFrame(() => inputRef.current?.focus()); };
@@ -55,6 +74,7 @@ export function AssistantWorkspace() {
55
74
  try {
56
75
  const result = await requestJson<AssistantSessionResponse>("/v1/admin/assistant/sessions", { method: "POST", headers: mutationHeaders(false) });
57
76
  setSessionId(result.sessionId);
77
+ try { sessionStorage.setItem(`safest-assistant:${shell.session?.actor.id}`, result.sessionId); } catch { /* Conversation remains usable without browser storage. */ }
58
78
  setHistory({ session: { id: result.sessionId, timezone: result.timezone, expiresAt: result.expiresAt }, messages: [], toolCalls: [] });
59
79
  setDraft("");
60
80
  requestAnimationFrame(() => inputRef.current?.focus());
@@ -70,10 +90,11 @@ export function AssistantWorkspace() {
70
90
  const send = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
71
91
  event.preventDefault();
72
92
  const body = draft.trim();
73
- if (!body || !canUse) return;
93
+ if (!body || !canUse || sendingRef.current || pending) return;
94
+ sendingRef.current = true;
74
95
  let activeSessionId = sessionId;
75
96
  if (!activeSessionId) activeSessionId = await createConversation() ?? "";
76
- if (!activeSessionId) return;
97
+ if (!activeSessionId) { sendingRef.current = false; return; }
77
98
  const optimistic: AssistantMessage = { id: crypto.randomUUID(), role: "user", body, citations: [], createdAt: new Date().toISOString() };
78
99
  setDraft("");
79
100
  setPending("send");
@@ -91,6 +112,7 @@ export function AssistantWorkspace() {
91
112
  setDraft(body);
92
113
  setError(errorMessage(cause, "Safest could not answer that question."));
93
114
  } finally {
115
+ sendingRef.current = false;
94
116
  setPending("");
95
117
  }
96
118
  };
@@ -103,6 +125,7 @@ export function AssistantWorkspace() {
103
125
  const onComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>): void => {
104
126
  if (event.key === "Enter" && !event.shiftKey) {
105
127
  event.preventDefault();
128
+ if (sendingRef.current || pending) return;
106
129
  event.currentTarget.form?.requestSubmit();
107
130
  }
108
131
  };