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
|
@@ -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;
|
|
@@ -68,6 +70,7 @@ export interface WorkflowRunRecord {
|
|
|
68
70
|
runKind?: string;
|
|
69
71
|
authorityMode?: string;
|
|
70
72
|
state: string;
|
|
73
|
+
stepCount?: number;
|
|
71
74
|
}
|
|
72
75
|
|
|
73
76
|
export interface TimelineEvent {
|
|
@@ -80,14 +83,22 @@ export interface TimelineEvent {
|
|
|
80
83
|
}
|
|
81
84
|
|
|
82
85
|
export interface FindingRecord {
|
|
86
|
+
runId?: string | null;
|
|
83
87
|
id?: string;
|
|
84
88
|
findingType: string;
|
|
85
89
|
summary: string;
|
|
90
|
+
componentKey?: string;
|
|
86
91
|
componentVersionId?: string;
|
|
92
|
+
componentVersion?: string | number;
|
|
93
|
+
subjectType?: string;
|
|
94
|
+
subjectReference?: string;
|
|
87
95
|
expiresAt?: string;
|
|
96
|
+
createdAt?: string;
|
|
88
97
|
freshness?: string;
|
|
89
98
|
status?: string;
|
|
90
99
|
data?: unknown;
|
|
100
|
+
provenance?: unknown;
|
|
101
|
+
uncertainty?: unknown;
|
|
91
102
|
}
|
|
92
103
|
|
|
93
104
|
export interface DecisionRecord {
|
|
@@ -109,13 +120,29 @@ export interface DeliveryAction {
|
|
|
109
120
|
}
|
|
110
121
|
|
|
111
122
|
export interface DeliveryNotice {
|
|
123
|
+
purpose?: "appeal" | "case";
|
|
112
124
|
id?: string;
|
|
125
|
+
messageId?: string;
|
|
126
|
+
decisionId?: string | null;
|
|
127
|
+
channel?: string | null;
|
|
128
|
+
attemptCount?: number;
|
|
129
|
+
errorCode?: string | null;
|
|
113
130
|
audience: string;
|
|
114
131
|
deliveryState: string;
|
|
115
132
|
updatedAt: string;
|
|
116
133
|
}
|
|
117
134
|
|
|
118
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;
|
|
119
146
|
id?: string;
|
|
120
147
|
recordKind: string;
|
|
121
148
|
actionCode: string;
|
|
@@ -146,6 +173,7 @@ export interface PolicyRule {
|
|
|
146
173
|
}
|
|
147
174
|
|
|
148
175
|
export interface ReportClaim {
|
|
176
|
+
version: number;
|
|
149
177
|
reviewerId: string;
|
|
150
178
|
expiresAt: string;
|
|
151
179
|
analyst?: AnalystIdentityData;
|
|
@@ -167,6 +195,11 @@ export interface ReportAnswer {
|
|
|
167
195
|
}
|
|
168
196
|
|
|
169
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;
|
|
170
203
|
id: string;
|
|
171
204
|
reference: string;
|
|
172
205
|
state: string;
|
|
@@ -204,11 +237,23 @@ export interface ReportDetailRecord {
|
|
|
204
237
|
workflowActions?: WorkflowActionRecord[];
|
|
205
238
|
aiRuns?: AiRunRecord[];
|
|
206
239
|
claim?: ReportClaim | null;
|
|
240
|
+
assignment?: ReportClaim | null;
|
|
241
|
+
handoffs?: Array<{ id: string; fromReviewerId: string; toReviewerId: string | null; note: string; authorName: string; createdAt: string }>;
|
|
207
242
|
}
|
|
208
243
|
|
|
209
|
-
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
|
+
}
|
|
210
253
|
|
|
211
254
|
export interface ReportDetailSnapshot {
|
|
255
|
+
reviewRequest?: number;
|
|
256
|
+
destination?: ReportDestination;
|
|
212
257
|
reportId: string | null;
|
|
213
258
|
loading: boolean;
|
|
214
259
|
error: string;
|
|
@@ -219,10 +264,13 @@ export interface ReportDetailSnapshot {
|
|
|
219
264
|
}
|
|
220
265
|
|
|
221
266
|
export interface ReportPermissions {
|
|
267
|
+
reassignReports: boolean;
|
|
268
|
+
manageConfiguration: boolean;
|
|
222
269
|
claimReports: boolean;
|
|
223
270
|
messageReporters: boolean;
|
|
224
271
|
decideReports: boolean;
|
|
225
272
|
readRuns: boolean;
|
|
273
|
+
readEvidence: boolean;
|
|
226
274
|
}
|
|
227
275
|
|
|
228
276
|
export interface ReportWorkspaceSnapshot {
|
|
@@ -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]) => <
|
|
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}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
|
|
2
2
|
import { createPortal } from "react-dom";
|
|
3
3
|
import { AnalystIdentity } from "../components/AnalystIdentity";
|
|
4
4
|
import { dispatchNavigation, dispatchSignOut } from "./events";
|
|
5
5
|
import { visibleNavigationGroups } from "./navigation";
|
|
6
|
+
import { WorkspaceUpdateNotice } from "./WorkspaceUpdateNotice";
|
|
6
7
|
import { getShellSnapshot, subscribeToShell } from "./store";
|
|
7
8
|
import type { ConsoleAnalystProfile, ConsoleSession, CustomerBrand, WorkspaceView } from "./types";
|
|
8
9
|
|
|
@@ -40,11 +41,12 @@ function useCustomerBrand(): CustomerBrand {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
function BrandLogo({ brand, size }: { brand: CustomerBrand; size: "brand" | "workspace" }) {
|
|
44
|
+
const [failedUrl, setFailedUrl] = useState("");
|
|
43
45
|
const isDefaultBrand = brand.organization_name.trim().toLowerCase() === "safest resolve";
|
|
44
46
|
const logoUrl = brand.logo_url || (isDefaultBrand ? "/brand-icon.svg" : "");
|
|
45
47
|
const alt = brand.logo_alt || (isDefaultBrand ? "Safest Resolve logo" : `${brand.organization_name} logo`);
|
|
46
|
-
if (logoUrl) {
|
|
47
|
-
return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
|
|
48
|
+
if (logoUrl && logoUrl !== failedUrl) {
|
|
49
|
+
return <img className={size === "workspace" ? "workspace-logo" : undefined} src={logoUrl} alt={alt} onError={() => setFailedUrl(logoUrl)} width={size === "workspace" ? 30 : 34} height={size === "workspace" ? 30 : 34} />;
|
|
48
50
|
}
|
|
49
51
|
return <span className={size === "workspace" ? "workspace-mark" : "customer-brand-fallback"} aria-hidden="true">{initials(brand.organization_name)}</span>;
|
|
50
52
|
}
|
|
@@ -64,40 +66,63 @@ function accessLabel(session: ConsoleSession): string {
|
|
|
64
66
|
return session.roles.map((role) => roleLabels[role] ?? role.replaceAll("_", " ")).join(", ") || "Team member";
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
function Navigation({ session, currentView,
|
|
69
|
+
function Navigation({ session, currentView, open, onClose }: {
|
|
68
70
|
session: ConsoleSession;
|
|
69
71
|
currentView: WorkspaceView;
|
|
70
|
-
reportCount: number;
|
|
71
72
|
open: boolean;
|
|
72
73
|
onClose: () => void;
|
|
73
74
|
}) {
|
|
74
75
|
const groups = useMemo(() => visibleNavigationGroups(session), [session]);
|
|
76
|
+
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
77
|
+
const navRef = useRef<HTMLElement>(null);
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open) return;
|
|
80
|
+
// Safari does not focus buttons on pointer clicks. Restore to the actual
|
|
81
|
+
// navigation trigger instead of relying on the previously focused element.
|
|
82
|
+
const previous = document.querySelector<HTMLButtonElement>(".nav-toggle") ?? document.activeElement;
|
|
83
|
+
navRef.current?.querySelector<HTMLElement>('a[aria-current="page"], a, button')?.focus();
|
|
84
|
+
return () => { if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true }); };
|
|
85
|
+
}, [open]);
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
// Opening a primary area must not move the next target under the pointer.
|
|
88
|
+
// Reveal a group only when navigating directly to one of its nested tools.
|
|
89
|
+
const active = groups.find(group => (group.administration ? group.items : group.items.slice(1)).some(item => item.view === currentView));
|
|
90
|
+
if (active) setExpanded(current => current[active.label] ? current : ({ ...current, [active.label]: true }));
|
|
91
|
+
}, [currentView, groups]);
|
|
75
92
|
const navigate = (event: MouseEvent<HTMLAnchorElement>, view: WorkspaceView): void => {
|
|
93
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
76
94
|
event.preventDefault();
|
|
77
95
|
dispatchNavigation(view);
|
|
78
96
|
onClose();
|
|
79
97
|
};
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
{groups.map((group) => (
|
|
85
|
-
<Fragment key={group.label}>
|
|
86
|
-
<small>{group.label}</small>
|
|
87
|
-
{group.items.map((item) => (
|
|
88
|
-
<a className={item.view === currentView ? "active" : undefined} data-nav={item.view} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
|
|
89
|
-
<svg aria-hidden="true"><use href={`#${item.icon}`} /></svg>
|
|
90
|
-
<span>{item.label}</span>
|
|
91
|
-
{item.count === "reports" ? <b id="nav-report-count" className="nav-count">{reportCount}</b> : null}
|
|
92
|
-
</a>
|
|
93
|
-
))}
|
|
94
|
-
</Fragment>
|
|
95
|
-
))}
|
|
96
|
-
</nav>
|
|
97
|
-
</aside>
|
|
98
|
-
<button className="nav-scrim" type="button" aria-label="Close navigation" hidden={!open} onClick={onClose} />
|
|
99
|
-
</>
|
|
98
|
+
const link = (item: (typeof groups)[number]["items"][number], primary = false) => (
|
|
99
|
+
<a className={item.view === currentView ? "active" : undefined} data-nav={item.view} data-primary-nav={primary || undefined} href={`#${item.view}`} key={item.view} aria-current={item.view === currentView ? "page" : undefined} onClick={(event) => navigate(event, item.view)}>
|
|
100
|
+
<svg aria-hidden="true"><use href={`#${item.icon}`} /></svg><span>{item.label}</span>
|
|
101
|
+
</a>
|
|
100
102
|
);
|
|
103
|
+
return <>
|
|
104
|
+
<aside ref={navRef} id="workspace-navigation" className={`sidebar${open ? " open" : ""}`} role={open ? "dialog" : undefined} aria-modal={open ? true : undefined} aria-label="Workspace navigation" onKeyDown={event => {
|
|
105
|
+
if (!open || event.key !== "Tab") return;
|
|
106
|
+
const controls = [...(navRef.current?.querySelectorAll<HTMLElement>('a, button') || [])].filter(item => item.getClientRects().length > 0);
|
|
107
|
+
const first = controls[0], last = controls.at(-1);
|
|
108
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last?.focus(); }
|
|
109
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first?.focus(); }
|
|
110
|
+
}}>
|
|
111
|
+
<div className="navigation-mobile-head"><strong>Workspace</strong><button type="button" className="icon-button" aria-label="Close workspace navigation" onClick={onClose}>×</button></div>
|
|
112
|
+
<nav aria-label="Product areas">
|
|
113
|
+
{groups.map(group => {
|
|
114
|
+
const [primary, ...secondary] = group.items;
|
|
115
|
+
const isExpanded = expanded[group.label] === true;
|
|
116
|
+
const panelId = `nav-area-${primary!.view}`;
|
|
117
|
+
return <div className={`navigation-area${group.administration ? " navigation-admin" : ""}`} key={group.label}>
|
|
118
|
+
{group.administration ? <button className="navigation-admin-toggle" type="button" aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><svg aria-hidden="true"><use href="#i-settings" /></svg><span>Workspace settings</span><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : <div className="navigation-primary">{link(primary!, true)}{secondary.length ? <button className="navigation-expand" type="button" aria-label={`${isExpanded ? "Hide" : "Show"} ${group.label} tools`} aria-expanded={isExpanded} aria-controls={panelId} onClick={() => setExpanded(current => ({ ...current, [group.label]: !isExpanded }))}><span aria-hidden="true">{isExpanded ? "−" : "+"}</span></button> : null}</div>}
|
|
119
|
+
<div className="navigation-secondary" id={panelId} hidden={!isExpanded}>{(group.administration ? group.items : secondary).map(item => link(item))}</div>
|
|
120
|
+
</div>;
|
|
121
|
+
})}
|
|
122
|
+
</nav>
|
|
123
|
+
</aside>
|
|
124
|
+
<button className="nav-scrim" type="button" aria-label="Close navigation" tabIndex={-1} hidden={!open} onClick={onClose} />
|
|
125
|
+
</>;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
128
|
export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLElement }) {
|
|
@@ -135,14 +160,14 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
|
|
|
135
160
|
</button>
|
|
136
161
|
<a className="brand" href="/" aria-label="Workspace home">
|
|
137
162
|
<BrandLogo brand={brand} size="brand" />
|
|
138
|
-
<span><strong>{brand.organization_name}</strong><small>
|
|
163
|
+
<span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
|
|
139
164
|
</a>
|
|
140
165
|
<div className="top-workspace" aria-label="Current workspace" hidden={!session}>
|
|
141
166
|
<BrandLogo brand={brand} size="workspace" />
|
|
142
|
-
<span><strong>{brand.organization_name}</strong><small>
|
|
167
|
+
<span><strong>{brand.organization_name}</strong><small>Case resolution</small></span>
|
|
143
168
|
</div>
|
|
144
169
|
<button className="global-command" type="button" hidden={!session || !canUseAssistant} onClick={() => dispatchNavigation("assistant")}>
|
|
145
|
-
<svg aria-hidden="true"><use href="#i-search" /></svg><span>
|
|
170
|
+
<svg aria-hidden="true"><use href="#i-search" /></svg><span>Ask Safest</span><kbd>⌘ K</kbd>
|
|
146
171
|
</button>
|
|
147
172
|
<div className="top-actions">
|
|
148
173
|
<span className={`health ${snapshot.health.status === "checking" ? "" : snapshot.health.status}`.trim()}><i aria-hidden="true" /><span>{snapshot.health.label}</span></span>
|
|
@@ -150,7 +175,8 @@ export function WorkspaceShell({ navigationTarget }: { navigationTarget: HTMLEle
|
|
|
150
175
|
{session ? <button className="sign-out-button" type="button" onClick={dispatchSignOut}>Sign out</button> : null}
|
|
151
176
|
</div>
|
|
152
177
|
</header>
|
|
153
|
-
|
|
178
|
+
<WorkspaceUpdateNotice />
|
|
179
|
+
{session ? createPortal(<Navigation session={session} currentView={snapshot.currentView} open={navigationOpen} onClose={() => setNavigationOpen(false)} />, navigationTarget) : null}
|
|
154
180
|
</>
|
|
155
181
|
);
|
|
156
182
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export function WorkspaceUpdateNotice() {
|
|
4
|
+
const [available, setAvailable] = useState(false);
|
|
5
|
+
useEffect(() => {
|
|
6
|
+
const current = document.querySelector<HTMLMetaElement>('meta[name="safest-release"]')?.content;
|
|
7
|
+
if (!current) return;
|
|
8
|
+
let stopped = false;
|
|
9
|
+
let pending = false;
|
|
10
|
+
let lastCheck = 0;
|
|
11
|
+
const controller = new AbortController();
|
|
12
|
+
const check = async () => {
|
|
13
|
+
if (stopped || pending || document.visibilityState === "hidden" || Date.now() - lastCheck < 30_000) return;
|
|
14
|
+
pending = true;
|
|
15
|
+
lastCheck = Date.now();
|
|
16
|
+
try {
|
|
17
|
+
const response = await fetch("/console/release.json", { cache: "no-store", signal: controller.signal });
|
|
18
|
+
if (!response.ok) return;
|
|
19
|
+
const value: unknown = await response.json();
|
|
20
|
+
if (!stopped && value && typeof value === "object" && "release" in value && typeof value.release === "string" && /^[a-f0-9]{20}$/.test(value.release)) {
|
|
21
|
+
setAvailable(value.release !== current);
|
|
22
|
+
}
|
|
23
|
+
} catch { /* Offline work must remain usable; retry on the next check. */ }
|
|
24
|
+
finally { pending = false; }
|
|
25
|
+
};
|
|
26
|
+
void check();
|
|
27
|
+
const timer = window.setInterval(() => { void check(); }, 60_000);
|
|
28
|
+
const resume = () => { void check(); };
|
|
29
|
+
window.addEventListener("pageshow", resume);
|
|
30
|
+
window.addEventListener("focus", resume);
|
|
31
|
+
window.addEventListener("online", resume);
|
|
32
|
+
document.addEventListener("visibilitychange", resume);
|
|
33
|
+
return () => {
|
|
34
|
+
stopped = true;
|
|
35
|
+
controller.abort();
|
|
36
|
+
window.clearInterval(timer);
|
|
37
|
+
window.removeEventListener("pageshow", resume);
|
|
38
|
+
window.removeEventListener("focus", resume);
|
|
39
|
+
window.removeEventListener("online", resume);
|
|
40
|
+
document.removeEventListener("visibilitychange", resume);
|
|
41
|
+
};
|
|
42
|
+
}, []);
|
|
43
|
+
if (!available) return null;
|
|
44
|
+
return <aside className="workspace-update" aria-label="Workspace update">
|
|
45
|
+
<p role="status"><strong>An update is ready.</strong> Save your work, then refresh to get the latest improvements.</p>
|
|
46
|
+
<button className="secondary" type="button" onClick={() => window.location.reload()}>Refresh workspace</button>
|
|
47
|
+
</aside>;
|
|
48
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { authEvents } from "../auth/events";
|
|
2
2
|
import { accountRouteFromHash } from "../auth/account-route";
|
|
3
|
+
import { readable } from "../lib/format";
|
|
3
4
|
import { errorMessage, hasSessionCredentials, requestJson } from "../lib/http";
|
|
4
5
|
import { shellEvents } from "./events";
|
|
5
6
|
import { visibleNavigationGroups } from "./navigation";
|
|
@@ -15,11 +16,14 @@ interface SessionResponse {
|
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
const viewTitles: Partial<Record<WorkspaceView, string>> = {
|
|
18
|
-
command: "
|
|
19
|
+
command: "Today",
|
|
20
|
+
reports: "Cases",
|
|
21
|
+
workflows: "Playbooks",
|
|
22
|
+
components: "Connections",
|
|
19
23
|
queues: "Queues and policies",
|
|
20
24
|
assistant: "Ask Safest",
|
|
21
25
|
"ai-quality": "AI quality",
|
|
22
|
-
analytics: "
|
|
26
|
+
analytics: "Insights",
|
|
23
27
|
settings: "Settings",
|
|
24
28
|
};
|
|
25
29
|
|
|
@@ -31,14 +35,6 @@ function element(id: "login" | "workspace" | "session-loading"): HTMLElement | n
|
|
|
31
35
|
return document.querySelector<HTMLElement>(`#${id}`);
|
|
32
36
|
}
|
|
33
37
|
|
|
34
|
-
function readable(value: string): string {
|
|
35
|
-
const copy = value.replaceAll("_", " ");
|
|
36
|
-
if (!copy) return "";
|
|
37
|
-
const sentence = `${copy.charAt(0).toUpperCase()}${copy.slice(1)}`;
|
|
38
|
-
const acronyms: Record<string, string> = { ai: "AI", api: "API", csv: "CSV", d1: "D1", http: "HTTP", https: "HTTPS", id: "ID", json: "JSON", r2: "R2", sla: "SLA", url: "URL" };
|
|
39
|
-
return sentence.replace(/\b(ai|api|csv|d1|http|https|id|json|r2|sla|url)\b/giu, (match) => acronyms[match.toLowerCase()] ?? match);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
38
|
function setWorkspaceMode(authenticated: boolean): void {
|
|
43
39
|
document.body.classList.remove("auth-resolving");
|
|
44
40
|
document.body.classList.toggle("workspace-mode", authenticated);
|
|
@@ -63,11 +59,11 @@ function allowedViews(session: ConsoleSession): Set<WorkspaceView> {
|
|
|
63
59
|
}
|
|
64
60
|
|
|
65
61
|
function setDocumentTitle(view: WorkspaceView): void {
|
|
66
|
-
const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "
|
|
62
|
+
const suffix = getShellSnapshot().session ? viewTitles[view] || readable(view) : "Case resolution";
|
|
67
63
|
document.title = `${brand.organization_name || "Safest Resolve"} — ${suffix}`;
|
|
68
64
|
}
|
|
69
65
|
|
|
70
|
-
function setView(requested: string): void {
|
|
66
|
+
function setView(requested: string, addHistory = false): void {
|
|
71
67
|
const snapshot = getShellSnapshot();
|
|
72
68
|
const session = snapshot.session;
|
|
73
69
|
if (!session) return;
|
|
@@ -78,9 +74,13 @@ function setView(requested: string): void {
|
|
|
78
74
|
? requestedView
|
|
79
75
|
: "command";
|
|
80
76
|
for (const section of available) section.hidden = section.dataset.view !== selected;
|
|
81
|
-
if (window.location.hash !== `#${selected}`)
|
|
77
|
+
if (window.location.hash !== `#${selected}`) {
|
|
78
|
+
if (addHistory) history.pushState(null, "", `#${selected}`);
|
|
79
|
+
else history.replaceState(null, "", `#${selected}`);
|
|
80
|
+
}
|
|
82
81
|
setShellSnapshot((current) => ({ ...current, currentView: selected }));
|
|
83
82
|
setDocumentTitle(selected);
|
|
83
|
+
if (addHistory) window.scrollTo({ top: 0, left: 0, behavior: "instant" });
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function expireWorkspaceSession(message = ""): void {
|
|
@@ -135,7 +135,7 @@ async function initialize(): Promise<void> {
|
|
|
135
135
|
const accountRoute = accountRouteFromHash(window.location.hash);
|
|
136
136
|
if (accountRoute.view !== "login") showWorkspace(false);
|
|
137
137
|
try {
|
|
138
|
-
brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "
|
|
138
|
+
brand = await window.SafestBrand?.loadBrand("/v1/brand", { title: "Case resolution" }) || brand;
|
|
139
139
|
} catch {
|
|
140
140
|
brand = window.SafestBrand?.defaults || brand;
|
|
141
141
|
}
|
|
@@ -170,17 +170,24 @@ export function startShellController(): void {
|
|
|
170
170
|
window.addEventListener(shellEvents.signOut, () => { void signOut(); });
|
|
171
171
|
window.addEventListener(shellEvents.navigate, (event) => {
|
|
172
172
|
const view = event instanceof CustomEvent && typeof event.detail?.view === "string" ? event.detail.view : "command";
|
|
173
|
-
setView(view);
|
|
173
|
+
setView(view, true);
|
|
174
174
|
if (view === "assistant") window.dispatchEvent(new Event("safest:assistant:focus"));
|
|
175
175
|
});
|
|
176
176
|
window.addEventListener("keydown", (event) => {
|
|
177
177
|
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k" && getShellSnapshot().session?.permissions.useAssistant) {
|
|
178
178
|
event.preventDefault();
|
|
179
|
-
setView("assistant");
|
|
179
|
+
setView("assistant", true);
|
|
180
180
|
window.dispatchEvent(new Event("safest:assistant:focus"));
|
|
181
181
|
}
|
|
182
182
|
});
|
|
183
183
|
window.addEventListener("hashchange", () => {
|
|
184
|
+
if (window.location.hash === "#main") {
|
|
185
|
+
const view = getShellSnapshot().currentView;
|
|
186
|
+
const section = document.querySelector<HTMLElement>(`[data-view="${view}"]`);
|
|
187
|
+
if (section) { section.tabIndex = -1; section.focus(); }
|
|
188
|
+
history.replaceState(null, "", `#${view}`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
184
191
|
if (getShellSnapshot().session && !window.location.hash.startsWith("#invite=")) setView(window.location.hash.slice(1) || "reports");
|
|
185
192
|
});
|
|
186
193
|
void initialize();
|
|
@@ -5,57 +5,48 @@ export interface NavigationItem {
|
|
|
5
5
|
label: string;
|
|
6
6
|
icon: string;
|
|
7
7
|
visible: (session: ConsoleSession) => boolean;
|
|
8
|
-
count?: "reports";
|
|
9
8
|
}
|
|
10
|
-
|
|
11
9
|
export interface NavigationGroup {
|
|
12
10
|
label: string;
|
|
13
11
|
items: NavigationItem[];
|
|
12
|
+
administration?: boolean;
|
|
14
13
|
}
|
|
15
|
-
|
|
16
14
|
const always = (): boolean => true;
|
|
17
15
|
const permission = (name: string) => (session: ConsoleSession): boolean => session.permissions[name] === true;
|
|
18
16
|
const anyPermission = (...names: string[]) => (session: ConsoleSession): boolean => names.some((name) => session.permissions[name] === true);
|
|
19
17
|
|
|
18
|
+
// Stable route names preserve existing bookmarks. The first item is the area;
|
|
19
|
+
// the other items are specialist tools belonging to that area.
|
|
20
20
|
export const navigationGroups: NavigationGroup[] = [
|
|
21
|
-
{
|
|
22
|
-
label: "
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
label: "
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
label: "
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
label: "Admin",
|
|
49
|
-
items: [
|
|
50
|
-
{ view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
|
|
51
|
-
{ view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
|
|
52
|
-
{ view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
|
|
53
|
-
],
|
|
54
|
-
},
|
|
21
|
+
{ label: "Today", items: [
|
|
22
|
+
{ view: "command", label: "Today", icon: "i-home", visible: always },
|
|
23
|
+
{ view: "assistant", label: "Ask Safest", icon: "i-spark", visible: permission("useAssistant") },
|
|
24
|
+
] },
|
|
25
|
+
{ label: "Cases", items: [
|
|
26
|
+
{ view: "reports", label: "Cases", icon: "i-inbox", visible: permission("readReports") },
|
|
27
|
+
{ view: "appeals", label: "Appeals", icon: "i-appeal", visible: permission("reviewAppeals") },
|
|
28
|
+
{ view: "queues", label: "Queues and policies", icon: "i-queue", visible: permission("readReports") },
|
|
29
|
+
] },
|
|
30
|
+
{ label: "Playbooks", items: [
|
|
31
|
+
{ view: "workflows", label: "Playbooks", icon: "i-workflow", visible: permission("manageWorkflows") },
|
|
32
|
+
{ view: "configuration", label: "Configuration", icon: "i-settings", visible: permission("manageConfiguration") },
|
|
33
|
+
] },
|
|
34
|
+
{ label: "Connections", items: [
|
|
35
|
+
{ view: "components", label: "Connections", icon: "i-component", visible: permission("manageComponents") },
|
|
36
|
+
] },
|
|
37
|
+
{ label: "Insights", items: [
|
|
38
|
+
{ view: "analytics", label: "Insights", icon: "i-chart", visible: permission("readAnalytics") },
|
|
39
|
+
{ view: "ai-quality", label: "AI quality", icon: "i-quality", visible: permission("reviewQuality") },
|
|
40
|
+
{ view: "operations", label: "Operations", icon: "i-activity", visible: permission("readOperations") },
|
|
41
|
+
] },
|
|
42
|
+
{ label: "Workspace settings", administration: true, items: [
|
|
43
|
+
{ view: "settings", label: "Settings", icon: "i-settings", visible: anyPermission("manageInstallation", "manageConfiguration", "manageIntegrations") },
|
|
44
|
+
{ view: "people", label: "People and roles", icon: "i-people", visible: permission("managePeople") },
|
|
45
|
+
{ view: "profile", label: "My profile", icon: "i-user", visible: (session) => session.actor.type === "operator_user" },
|
|
46
|
+
] },
|
|
55
47
|
];
|
|
56
48
|
|
|
57
49
|
export function visibleNavigationGroups(session: ConsoleSession): NavigationGroup[] {
|
|
58
|
-
return navigationGroups
|
|
59
|
-
.map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
|
|
50
|
+
return navigationGroups.map((group) => ({ ...group, items: group.items.filter((item) => item.visible(session)) }))
|
|
60
51
|
.filter((group) => group.items.length > 0);
|
|
61
52
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { compilePlaybook, playbookStarterFixtures, starterPlaybook } from "../../shared/playbooks";
|
|
3
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
4
|
+
import { announceWorkflowChanged, openWorkflowStudio } from "./events";
|
|
5
|
+
import { guideSteps, PlaybookGuideEditor, usePlaybookResources } from "./PlaybookGuideEditor";
|
|
6
|
+
import type { WorkflowCreateResponse } from "./types";
|
|
7
|
+
import { validateGuideInputs } from "./PlaybookFields";
|
|
8
|
+
|
|
9
|
+
export const openPlaybookCreate = () => window.dispatchEvent(new CustomEvent("safest:playbook:create"));
|
|
10
|
+
|
|
11
|
+
export function CreatePlaybookDialog() {
|
|
12
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
13
|
+
const nameRef = useRef<HTMLInputElement>(null);
|
|
14
|
+
const pendingRef = useRef(false);
|
|
15
|
+
const requestKey = useRef(crypto.randomUUID());
|
|
16
|
+
const createdRef = useRef<WorkflowCreateResponse | null>(null);
|
|
17
|
+
const [open, setOpen] = useState(false);
|
|
18
|
+
const [name, setName] = useState("");
|
|
19
|
+
const [startingPoint, setStartingPoint] = useState<"unwanted-contact" | "description" | "policy">("unwanted-contact");
|
|
20
|
+
const [guide, setGuide] = useState(() => starterPlaybook("unwanted-contact"));
|
|
21
|
+
const [step, setStep] = useState(-1);
|
|
22
|
+
const [pending, setPending] = useState(false);
|
|
23
|
+
const [error, setError] = useState("");
|
|
24
|
+
const { resources, error: resourceError, reload } = usePlaybookResources(open);
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const show = () => { setOpen(true); setStep(-1); setName(""); setStartingPoint("unwanted-contact"); setGuide(starterPlaybook("unwanted-contact")); setError(""); requestKey.current = crypto.randomUUID(); createdRef.current = null; };
|
|
27
|
+
window.addEventListener("safest:playbook:create", show);
|
|
28
|
+
return () => window.removeEventListener("safest:playbook:create", show);
|
|
29
|
+
}, []);
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
if (open && !dialogRef.current?.open) { dialogRef.current?.showModal(); requestAnimationFrame(() => nameRef.current?.focus()); }
|
|
32
|
+
if (!open && dialogRef.current?.open) dialogRef.current.close();
|
|
33
|
+
}, [open]);
|
|
34
|
+
const dirty = Boolean(name || startingPoint !== "unwanted-contact" || JSON.stringify(guide) !== JSON.stringify(starterPlaybook("unwanted-contact")));
|
|
35
|
+
const close = () => { if (!pendingRef.current && (!dirty || window.confirm("Discard this unsaved playbook draft?"))) setOpen(false); };
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const unload = (event: BeforeUnloadEvent) => { if (open && (dirty || pendingRef.current)) event.preventDefault(); };
|
|
38
|
+
window.addEventListener("beforeunload", unload);
|
|
39
|
+
return () => window.removeEventListener("beforeunload", unload);
|
|
40
|
+
}, [open, dirty]);
|
|
41
|
+
const submit = async (event: FormEvent<HTMLFormElement>) => {
|
|
42
|
+
event.preventDefault();
|
|
43
|
+
if (pendingRef.current) return;
|
|
44
|
+
if (step < guideSteps.length - 1) { setStep(current => current + 1); return; }
|
|
45
|
+
pendingRef.current = true; setPending(true); setError("");
|
|
46
|
+
try {
|
|
47
|
+
validateGuideInputs(guide, resources);
|
|
48
|
+
const graph = compilePlaybook(guide);
|
|
49
|
+
const key = `${name.toLowerCase().replace(/[^a-z0-9]+/gu, "-").replace(/^-|-$/gu, "").slice(0, 70) || "playbook"}-${requestKey.current.slice(0, 8)}`;
|
|
50
|
+
const result = createdRef.current ?? await requestJson<WorkflowCreateResponse>("/v1/admin/workflows", { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": requestKey.current }, body: JSON.stringify({ key, name, description: guide.summary, purpose: "report", graph }) });
|
|
51
|
+
createdRef.current = result;
|
|
52
|
+
for (const [index, fixture] of playbookStarterFixtures(guide).entries()) await requestJson(`/v1/admin/workflows/${encodeURIComponent(result.workflowId)}/fixtures`, { method: "POST", headers: { ...mutationHeaders(), "idempotency-key": `${requestKey.current}:example:${index}` }, body: JSON.stringify(fixture) });
|
|
53
|
+
setOpen(false); announceWorkflowChanged(); openWorkflowStudio(result.workflowId);
|
|
54
|
+
} catch (cause) { setError(errorMessage(cause, createdRef.current ? "The draft was saved, but its examples could not be added. Retry to finish without creating another draft." : "The playbook could not be created. Your changes are still here.")); }
|
|
55
|
+
finally { pendingRef.current = false; setPending(false); }
|
|
56
|
+
};
|
|
57
|
+
return <dialog ref={dialogRef} className="editor-dialog playbook-create-dialog" aria-labelledby="playbook-create-title" onCancel={event => { event.preventDefault(); close(); }}>
|
|
58
|
+
<div className="detail-head"><div><h2 id="playbook-create-title">Create a playbook</h2><p>Teach Safest how your team handles a situation.</p></div><button className="icon-button" type="button" aria-label="Close playbook creation" disabled={pending} onClick={close}>×</button></div>
|
|
59
|
+
<form className="editor-form" onSubmit={event => void submit(event)}>
|
|
60
|
+
{step < 0 ? <>
|
|
61
|
+
<label>Playbook name<input ref={nameRef} required maxLength={160} value={name} disabled={pending} onChange={event => setName(event.currentTarget.value)} placeholder="For example, unwanted contact" /></label>
|
|
62
|
+
<fieldset><legend>Choose a starting point</legend>{([{ id: "unwanted-contact", name: "Unwanted contact", detail: "Reports of repeated contact, reviewed with context and a clear human decision." }, { id: "description", name: "Describe your own situation", detail: "Describe what your team needs to resolve, then choose evidence and outcomes." }, { id: "policy", name: "Start from your policy", detail: "Paste the relevant policy, including exceptions, then connect it to a published rule." }] as const).map(starter => <label className="template-choice" key={starter.id}><input type="radio" name="playbook-starter" checked={startingPoint === starter.id} onChange={() => { if (startingPoint !== starter.id && (guide.summary !== starterPlaybook(startingPoint === "unwanted-contact" ? "unwanted-contact" : "general-review").summary || guide.policy_text !== starterPlaybook("general-review").policy_text) && !window.confirm("Replace the entered description and policy with this starting point?")) return; setStartingPoint(starter.id); setGuide(starterPlaybook(starter.id === "unwanted-contact" ? "unwanted-contact" : "general-review")); }} /><span><strong>{starter.name}</strong><small>{starter.detail}</small></span></label>)}</fieldset>
|
|
63
|
+
{startingPoint === "description" ? <label>Describe the situation<textarea required rows={5} maxLength={2000} value={guide.summary} onChange={event => setGuide({ ...guide, summary: event.currentTarget.value })} /><small>Explain the concern, what evidence matters and when someone should review it. The next steps turn your description into explicit choices.</small></label> : startingPoint === "policy" ? <label>Paste your policy<textarea required rows={7} maxLength={20000} value={guide.policy_text} onChange={event => setGuide({ ...guide, policy_text: event.currentTarget.value })} /><small>The full text stays in the draft. You will choose its scope, published rule and permitted outcomes before testing.</small></label> : null}
|
|
64
|
+
<div className="playbook-callout"><strong>A draft first, then examples and publication.</strong><p>Nothing starts handling cases until a published version is activated.</p></div>
|
|
65
|
+
</> : <>
|
|
66
|
+
<ol className="playbook-progress" aria-label="Playbook creation steps">{guideSteps.map((label, index) => <li key={label} aria-current={index === step ? "step" : undefined}><span>{index + 1}</span>{label}</li>)}</ol>
|
|
67
|
+
<PlaybookGuideEditor value={guide} onChange={setGuide} step={step} resources={resources} disabled={pending || Boolean(createdRef.current)} />
|
|
68
|
+
</>}
|
|
69
|
+
{resourceError ? <div role="alert" className="playbook-callout">{resourceError} <button className="text-button" type="button" onClick={reload}>Retry loading connections</button></div> : null}
|
|
70
|
+
<div className="editor-footer"><p className="error" role="alert">{error}</p><div><button className="secondary" type="button" disabled={pending || Boolean(createdRef.current)} onClick={() => step < 0 ? close() : setStep(current => current - 1)}>{step < 0 ? "Cancel" : "Back"}</button><button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : step === guideSteps.length - 1 ? "Create draft and examples" : "Continue"}</button></div></div>
|
|
71
|
+
</form>
|
|
72
|
+
</dialog>;
|
|
73
|
+
}
|