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.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- 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"
|
|
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
|
|
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 ?
|
|
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
|
|
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 || !
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
"
|
|
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
|
-
|
|
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
|
};
|