create-safest-tools 0.2.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/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/create-safest-tools.mjs +12 -0
- package/package.json +34 -0
- package/src/cli.mjs +128 -0
- package/src/config.mjs +151 -0
- package/src/scaffold.mjs +30 -0
- package/template/LICENSE +201 -0
- package/template/README.md +35 -0
- package/template/console/README.md +28 -0
- package/template/console/analytics/AnalyticsWorkspace.tsx +55 -0
- package/template/console/analytics/controller.ts +133 -0
- package/template/console/analytics/events.ts +33 -0
- package/template/console/analytics/store.ts +33 -0
- package/template/console/analytics/types.ts +38 -0
- package/template/console/appeals/AppealWorkspace.tsx +73 -0
- package/template/console/appeals/controller.ts +125 -0
- package/template/console/appeals/events.ts +33 -0
- package/template/console/appeals/store.ts +35 -0
- package/template/console/appeals/types.ts +37 -0
- package/template/console/assistant/AssistantWorkspace.tsx +133 -0
- package/template/console/assistant/types.ts +34 -0
- package/template/console/auth/AccountJourney.tsx +69 -0
- package/template/console/auth/InvitationAcceptance.tsx +145 -0
- package/template/console/auth/OAuthButtons.tsx +78 -0
- package/template/console/auth/OperatorLogin.tsx +110 -0
- package/template/console/auth/PasswordRecovery.tsx +132 -0
- package/template/console/auth/account-route.ts +15 -0
- package/template/console/auth/browser-navigation.ts +7 -0
- package/template/console/auth/events.ts +15 -0
- package/template/console/command/CommandCentre.tsx +119 -0
- package/template/console/command/controller.ts +267 -0
- package/template/console/command/events.ts +19 -0
- package/template/console/command/store.ts +36 -0
- package/template/console/command/types.ts +61 -0
- package/template/console/components/AnalystIdentity.tsx +25 -0
- package/template/console/components/PageHeader.tsx +27 -0
- package/template/console/configuration/ConfigurationDialog.tsx +200 -0
- package/template/console/configuration/ConfigurationWorkspace.tsx +66 -0
- package/template/console/configuration/FormBuilder.tsx +143 -0
- package/template/console/configuration/api.ts +40 -0
- package/template/console/configuration/events.ts +14 -0
- package/template/console/configuration/types.ts +79 -0
- package/template/console/lib/format.ts +49 -0
- package/template/console/lib/http.ts +96 -0
- package/template/console/main.tsx +225 -0
- package/template/console/operations/OperationsWorkspace.tsx +126 -0
- package/template/console/operations/controller.ts +237 -0
- package/template/console/operations/events.ts +37 -0
- package/template/console/operations/store.ts +32 -0
- package/template/console/operations/types.ts +107 -0
- package/template/console/people/PeopleWorkspace.tsx +81 -0
- package/template/console/people/types.ts +25 -0
- package/template/console/profile/ProfileWorkspace.tsx +136 -0
- package/template/console/profile/events.ts +9 -0
- package/template/console/profile/types.ts +6 -0
- package/template/console/quality/QualityWorkspace.tsx +87 -0
- package/template/console/quality/controller.ts +154 -0
- package/template/console/quality/events.ts +34 -0
- package/template/console/quality/store.ts +36 -0
- package/template/console/quality/types.ts +70 -0
- package/template/console/queues/QueueEditor.tsx +173 -0
- package/template/console/queues/QueueWorkspace.tsx +109 -0
- package/template/console/queues/controller.ts +194 -0
- package/template/console/queues/events.ts +34 -0
- package/template/console/queues/store.ts +37 -0
- package/template/console/queues/types.ts +102 -0
- package/template/console/registry/RegistryDialog.tsx +198 -0
- package/template/console/registry/RegistryWorkspace.tsx +108 -0
- package/template/console/registry/events.ts +14 -0
- package/template/console/registry/types.ts +52 -0
- package/template/console/reports/ReportDrawer.tsx +159 -0
- package/template/console/reports/ReportWorkspace.tsx +90 -0
- package/template/console/reports/controller.ts +517 -0
- package/template/console/reports/events.ts +42 -0
- package/template/console/reports/store.ts +39 -0
- package/template/console/reports/types.ts +230 -0
- package/template/console/settings/BrandEditor.tsx +126 -0
- package/template/console/settings/ChannelDialog.tsx +241 -0
- package/template/console/settings/SettingsWorkspace.tsx +155 -0
- package/template/console/settings/events.ts +19 -0
- package/template/console/settings/handoff.ts +22 -0
- package/template/console/settings/types.ts +93 -0
- package/template/console/shell/WorkspaceShell.tsx +160 -0
- package/template/console/shell/controller.ts +182 -0
- package/template/console/shell/events.ts +25 -0
- package/template/console/shell/navigation.ts +61 -0
- package/template/console/shell/store.ts +56 -0
- package/template/console/shell/types.ts +86 -0
- package/template/console/workflows/CreateWorkflowDialog.tsx +87 -0
- package/template/console/workflows/WorkflowCanvas.tsx +42 -0
- package/template/console/workflows/WorkflowDialogs.tsx +6 -0
- package/template/console/workflows/WorkflowStudio.tsx +331 -0
- package/template/console/workflows/WorkflowWorkspace.tsx +82 -0
- package/template/console/workflows/events.ts +17 -0
- package/template/console/workflows/graph.ts +156 -0
- package/template/console/workflows/templates.ts +70 -0
- package/template/console/workflows/types.ts +201 -0
- package/template/gitignore.template +18 -0
- package/template/migrations/0001_reports_foundation.sql +444 -0
- package/template/migrations/0002_human_report_loop.sql +65 -0
- package/template/migrations/0003_delivery_reliability.sql +18 -0
- package/template/migrations/0004_public_intake.sql +14 -0
- package/template/migrations/0005_operations_visibility.sql +24 -0
- package/template/migrations/0006_ai_governance.sql +198 -0
- package/template/migrations/0007_ai_release_gates.sql +6 -0
- package/template/migrations/0008_retention_analytics_exports.sql +52 -0
- package/template/migrations/0009_retention_derived_copies.sql +14 -0
- package/template/migrations/0010_ai_quality_controls.sql +26 -0
- package/template/migrations/0011_analyst_presence.sql +28 -0
- package/template/migrations/0012_queue_policies.sql +87 -0
- package/template/migrations/0013_routing_agents.sql +63 -0
- package/template/migrations/0014_webhook_enrichments.sql +122 -0
- package/template/migrations/0015_queue_owned_ai.sql +55 -0
- package/template/migrations/0016_operator_accounts.sql +67 -0
- package/template/migrations/0017_operator_profiles_and_recovery.sql +33 -0
- package/template/migrations/0018_platform_configuration.sql +382 -0
- package/template/migrations/0019_workflow_authoring_runtime.sql +372 -0
- package/template/migrations/0020_tasks_findings_assistant_budgets.sql +427 -0
- package/template/migrations/0021_abuse_evidence_operations.sql +324 -0
- package/template/migrations/0022_workflow_dispatch_operations.sql +42 -0
- package/template/migrations/0023_component_connection_execution.sql +75 -0
- package/template/migrations/0024_access_runtime_integrity.sql +72 -0
- package/template/migrations/0025_installation_timezone.sql +11 -0
- package/template/migrations/0026_ai_and_egress_execution_controls.sql +49 -0
- package/template/migrations/0027_prompt_and_ai_registry.sql +37 -0
- package/template/migrations/0028_step_attempt_ai_provenance.sql +13 -0
- package/template/migrations/0029_evidence_fetch_transport.sql +5 -0
- package/template/migrations/0030_evidence_dlq_incidents.sql +45 -0
- package/template/migrations/0031_shadow_quality_integrity.sql +7 -0
- package/template/migrations/0032_action_delivery_outbox.sql +55 -0
- package/template/migrations/0033_configuration_and_assistant_drafts.sql +43 -0
- package/template/migrations/0034_installation_integrations.sql +31 -0
- package/template/migrations/0035_workspace_governance.sql +21 -0
- package/template/migrations/0036_published_routing_baseline.sql +15 -0
- package/template/migrations/0037_builtin_phishing_specialist.sql +71 -0
- package/template/migrations/0038_remove_deprecated_enrichment_runtime.sql +228 -0
- package/template/migrations/0039_secure_reporting_channels.sql +45 -0
- package/template/migrations/0040_notification_only_reporting.sql +24 -0
- package/template/migrations/0041_better_auth_credentials.sql +17 -0
- package/template/package.json +47 -0
- package/template/public/_headers +27 -0
- package/template/public/app-icon-192.png +0 -0
- package/template/public/app-icon-512.png +0 -0
- package/template/public/brand-icon.svg +7 -0
- package/template/public/brand-tokens.css +80 -0
- package/template/public/console/auth-shell.js +19340 -0
- package/template/public/customer-brand.js +58 -0
- package/template/public/embed/embed.css +139 -0
- package/template/public/embed/embed.js +408 -0
- package/template/public/embed/index.html +91 -0
- package/template/public/favicon.svg +7 -0
- package/template/public/fonts/Manrope-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Italic-Variable.ttf +0 -0
- package/template/public/fonts/Newsreader-Variable.ttf +0 -0
- package/template/public/index.html +123 -0
- package/template/public/logo-primary.svg +7 -0
- package/template/public/logo-reversed.svg +7 -0
- package/template/public/manifest.webmanifest +21 -0
- package/template/public/public-report.js +184 -0
- package/template/public/report/index.html +39 -0
- package/template/public/report/public-report.css +20 -0
- package/template/public/social-card.png +0 -0
- package/template/public/styles.css +1521 -0
- package/template/public/widget.css +80 -0
- package/template/public/widget.js +220 -0
- package/template/reports.config.example.json +38 -0
- package/template/reports.schema.json +89 -0
- package/template/scripts/reports-auth-onboarding.mjs +180 -0
- package/template/scripts/reports-backup.mjs +276 -0
- package/template/scripts/reports-cloudflare-preflight.mjs +152 -0
- package/template/scripts/reports-deploy.mjs +173 -0
- package/template/scripts/reports-plan.mjs +226 -0
- package/template/scripts/reports-restore.mjs +180 -0
- package/template/scripts/reports-secrets.mjs +73 -0
- package/template/scripts/reports-uninstall-plan.mjs +32 -0
- package/template/src/agent-executor.ts +330 -0
- package/template/src/ai-observability.ts +163 -0
- package/template/src/ai-registry-validation.ts +244 -0
- package/template/src/ai-registry.ts +292 -0
- package/template/src/api-cursor.ts +84 -0
- package/template/src/assistant.ts +554 -0
- package/template/src/audit.ts +39 -0
- package/template/src/backup-service.ts +269 -0
- package/template/src/budget-control.ts +127 -0
- package/template/src/component-executor.ts +845 -0
- package/template/src/configuration-registry.ts +550 -0
- package/template/src/connection-egress.ts +430 -0
- package/template/src/connection-oauth.ts +368 -0
- package/template/src/evidence-service.ts +335 -0
- package/template/src/human-tasks.ts +316 -0
- package/template/src/index.ts +4114 -0
- package/template/src/installation-admin.ts +301 -0
- package/template/src/intake-abuse.ts +156 -0
- package/template/src/platform-registry-validation.ts +367 -0
- package/template/src/platform-registry.ts +753 -0
- package/template/src/report-admin.ts +342 -0
- package/template/src/report-ai-quality.ts +390 -0
- package/template/src/report-ai-validation.ts +80 -0
- package/template/src/report-ai.ts +857 -0
- package/template/src/report-auth.ts +292 -0
- package/template/src/report-better-auth.ts +583 -0
- package/template/src/report-context-schema.ts +132 -0
- package/template/src/report-context.ts +126 -0
- package/template/src/report-crypto.ts +142 -0
- package/template/src/report-delivery.ts +579 -0
- package/template/src/report-form-validation.ts +51 -0
- package/template/src/report-governance.ts +510 -0
- package/template/src/report-http.ts +90 -0
- package/template/src/report-operations.ts +685 -0
- package/template/src/report-operator-accounts.ts +810 -0
- package/template/src/report-presence.ts +398 -0
- package/template/src/report-queue-validation.ts +309 -0
- package/template/src/report-queues.ts +478 -0
- package/template/src/report-repository.ts +972 -0
- package/template/src/report-router-agent.ts +328 -0
- package/template/src/report-router-validation.ts +81 -0
- package/template/src/report-routing.ts +178 -0
- package/template/src/report-turnstile.ts +142 -0
- package/template/src/report-types.ts +224 -0
- package/template/src/report-validation.ts +279 -0
- package/template/src/report-workflow-validation.ts +119 -0
- package/template/src/report-workflow.ts +886 -0
- package/template/src/shadow-quality.ts +278 -0
- package/template/src/workflow-actions.ts +782 -0
- package/template/src/workflow-compiler.ts +230 -0
- package/template/src/workflow-dynamic-runtime.ts +613 -0
- package/template/src/workflow-effects.ts +316 -0
- package/template/src/workflow-expressions.ts +191 -0
- package/template/src/workflow-platform-types.ts +121 -0
- package/template/src/workflow-platform-validation.ts +540 -0
- package/template/src/workflow-repository.ts +916 -0
- package/template/src/workflow-runs.ts +686 -0
- package/template/src/workflow-simulator.ts +211 -0
- package/template/src/workspace-branding.ts +128 -0
- package/template/src/workspace-governance.ts +279 -0
- package/template/tsconfig.console.json +23 -0
- package/template/tsconfig.json +22 -0
- package/template/vite.console.config.ts +20 -0
- package/template/worker-configuration.d.ts +65 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { isOperationsWorkspaceSnapshot, operationsEvents } from "./events";
|
|
2
|
+
import type { OperationsWorkspaceSnapshot } from "./types";
|
|
3
|
+
|
|
4
|
+
const subscribers = new Set<() => void>();
|
|
5
|
+
|
|
6
|
+
let snapshot: OperationsWorkspaceSnapshot = {
|
|
7
|
+
loading: true,
|
|
8
|
+
refreshing: false,
|
|
9
|
+
error: "",
|
|
10
|
+
canRead: false,
|
|
11
|
+
canOperate: false,
|
|
12
|
+
operations: null,
|
|
13
|
+
incidents: [],
|
|
14
|
+
filters: { state: "", severity: "" },
|
|
15
|
+
detail: { incidentId: null, loading: false, error: "", pendingAction: null, record: null },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
window.addEventListener(operationsEvents.state, (event) => {
|
|
19
|
+
if (!(event instanceof CustomEvent) || !isOperationsWorkspaceSnapshot(event.detail)) return;
|
|
20
|
+
snapshot = event.detail;
|
|
21
|
+
for (const subscriber of subscribers) subscriber();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function getOperationsWorkspaceSnapshot(): OperationsWorkspaceSnapshot { return snapshot; }
|
|
25
|
+
export function setOperationsWorkspaceSnapshot(next: OperationsWorkspaceSnapshot | ((current: OperationsWorkspaceSnapshot) => OperationsWorkspaceSnapshot)): void {
|
|
26
|
+
snapshot = typeof next === "function" ? next(snapshot) : next;
|
|
27
|
+
for (const subscriber of subscribers) subscriber();
|
|
28
|
+
}
|
|
29
|
+
export function subscribeToOperationsWorkspace(callback: () => void): () => void {
|
|
30
|
+
subscribers.add(callback);
|
|
31
|
+
return () => subscribers.delete(callback);
|
|
32
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
export type OperationsPendingAction = "acknowledge" | "retry" | "resolve" | null;
|
|
2
|
+
export type OperationsIncidentStateFilter = "" | "open" | "acknowledged" | "mitigated" | "resolved";
|
|
3
|
+
export type OperationsIncidentSeverityFilter = "" | "info" | "low" | "medium" | "high" | "critical";
|
|
4
|
+
|
|
5
|
+
export interface OperationsSnapshotCounts {
|
|
6
|
+
generatedAt: string;
|
|
7
|
+
routing: { failed: number; deadLetter: number };
|
|
8
|
+
actions: { retrying: number; terminalFailed: number };
|
|
9
|
+
notifications: { retrying: number; terminalFailed: number };
|
|
10
|
+
emails: { retrying: number; terminalFailed: number };
|
|
11
|
+
incidents: { open: number; acknowledged: number; highPriority: number };
|
|
12
|
+
deadLetters: OperationsDeadLetter[];
|
|
13
|
+
deliveryFailures: OperationsDeliveryFailure[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OperationsDeadLetter {
|
|
17
|
+
id: string;
|
|
18
|
+
jobType: string;
|
|
19
|
+
jobId?: string | null;
|
|
20
|
+
state: string;
|
|
21
|
+
errorCode?: string | null;
|
|
22
|
+
occurrenceCount: number;
|
|
23
|
+
lastSeenAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface OperationsDeliveryFailure {
|
|
27
|
+
id: string;
|
|
28
|
+
deliveryType: string;
|
|
29
|
+
reportReference: string;
|
|
30
|
+
status: string;
|
|
31
|
+
attemptCount: number;
|
|
32
|
+
errorCode?: string | null;
|
|
33
|
+
updatedAt: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OperationsIncidentSummary {
|
|
37
|
+
id: string;
|
|
38
|
+
incidentKey: string;
|
|
39
|
+
severity: string;
|
|
40
|
+
category: string;
|
|
41
|
+
state: string;
|
|
42
|
+
title: string;
|
|
43
|
+
summary: string;
|
|
44
|
+
entityType?: string | null;
|
|
45
|
+
entityId?: string | null;
|
|
46
|
+
occurrenceCount: number;
|
|
47
|
+
firstSeenAt: string;
|
|
48
|
+
lastSeenAt: string;
|
|
49
|
+
acknowledgedBy?: string | null;
|
|
50
|
+
acknowledgedAt?: string | null;
|
|
51
|
+
resolvedBy?: string | null;
|
|
52
|
+
resolvedAt?: string | null;
|
|
53
|
+
resolutionSummary?: string | null;
|
|
54
|
+
metadata?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OperationsIncidentRecord {
|
|
58
|
+
incident: OperationsIncidentSummary;
|
|
59
|
+
deadLetters: Array<{
|
|
60
|
+
id: string;
|
|
61
|
+
jobType: string;
|
|
62
|
+
jobId?: string | null;
|
|
63
|
+
state: string;
|
|
64
|
+
errorCode?: string | null;
|
|
65
|
+
occurrenceCount: number;
|
|
66
|
+
lastSeenAt: string;
|
|
67
|
+
}>;
|
|
68
|
+
repairs: Array<{
|
|
69
|
+
id: string;
|
|
70
|
+
actionType: string;
|
|
71
|
+
entityType: string;
|
|
72
|
+
entityId: string;
|
|
73
|
+
state: string;
|
|
74
|
+
plannedBy?: string | null;
|
|
75
|
+
plannedAt: string;
|
|
76
|
+
appliedAt?: string | null;
|
|
77
|
+
errorCode?: string | null;
|
|
78
|
+
}>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface OperationsFilters {
|
|
82
|
+
state: OperationsIncidentStateFilter;
|
|
83
|
+
severity: OperationsIncidentSeverityFilter;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface OperationsWorkspaceSnapshot {
|
|
87
|
+
loading: boolean;
|
|
88
|
+
refreshing: boolean;
|
|
89
|
+
error: string;
|
|
90
|
+
canRead: boolean;
|
|
91
|
+
canOperate: boolean;
|
|
92
|
+
operations: OperationsSnapshotCounts | null;
|
|
93
|
+
incidents: OperationsIncidentSummary[];
|
|
94
|
+
filters: OperationsFilters;
|
|
95
|
+
detail: {
|
|
96
|
+
incidentId: string | null;
|
|
97
|
+
loading: boolean;
|
|
98
|
+
error: string;
|
|
99
|
+
pendingAction: OperationsPendingAction;
|
|
100
|
+
record: OperationsIncidentRecord | null;
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface OperationsResolutionInput {
|
|
105
|
+
resolutionCode: string;
|
|
106
|
+
summary: string;
|
|
107
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { useEffect, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
2
|
+
import { AnalystIdentity } from "../components/AnalystIdentity";
|
|
3
|
+
import { PageHeader } from "../components/PageHeader";
|
|
4
|
+
import { errorMessage, postJson, requestJson } from "../lib/http";
|
|
5
|
+
import { relativeTime } from "../lib/format";
|
|
6
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
7
|
+
import type { InvitationResponse, PasswordResetResponse, PeopleResponse, WorkspaceInvitation, WorkspacePerson } from "./types";
|
|
8
|
+
|
|
9
|
+
const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
|
|
10
|
+
const roleName = (role: string): string => roleNames[role] ?? role.replaceAll("_", " ");
|
|
11
|
+
|
|
12
|
+
async function copy(value: string): Promise<void> {
|
|
13
|
+
await navigator.clipboard.writeText(value);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function PeopleWorkspace() {
|
|
17
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
18
|
+
const [people, setPeople] = useState<WorkspacePerson[]>([]);
|
|
19
|
+
const [invitations, setInvitations] = useState<WorkspaceInvitation[]>([]);
|
|
20
|
+
const [email, setEmail] = useState("");
|
|
21
|
+
const [role, setRole] = useState("reviewer");
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
const [pending, setPending] = useState("");
|
|
24
|
+
const [error, setError] = useState("");
|
|
25
|
+
const [inviteUrl, setInviteUrl] = useState("");
|
|
26
|
+
const [resetMessage, setResetMessage] = useState("");
|
|
27
|
+
const canManage = shell.session?.permissions.managePeople === true;
|
|
28
|
+
const isOwner = shell.session?.roles.includes("owner") === true;
|
|
29
|
+
|
|
30
|
+
const load = async (): Promise<void> => {
|
|
31
|
+
if (!canManage) { setPeople([]); setInvitations([]); setLoading(false); return; }
|
|
32
|
+
setError("");
|
|
33
|
+
try {
|
|
34
|
+
const result = await requestJson<PeopleResponse>("/v1/admin/people");
|
|
35
|
+
setPeople(Array.isArray(result.users) ? result.users : []);
|
|
36
|
+
setInvitations(Array.isArray(result.invitations) ? result.invitations : []);
|
|
37
|
+
} catch (cause) { setError(errorMessage(cause, "People could not be loaded.")); }
|
|
38
|
+
finally { setLoading(false); }
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); void load(); }, [shell.session?.actor.id, canManage]);
|
|
42
|
+
|
|
43
|
+
const invite = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
44
|
+
event.preventDefault(); setPending("invite"); setError(""); setInviteUrl("");
|
|
45
|
+
try {
|
|
46
|
+
const result = await postJson<InvitationResponse>("/v1/admin/people", { email, role });
|
|
47
|
+
setInviteUrl(result.invitation.inviteUrl); setEmail(""); await load();
|
|
48
|
+
} catch (cause) { setError(errorMessage(cause, "The invitation could not be created.")); }
|
|
49
|
+
finally { setPending(""); }
|
|
50
|
+
};
|
|
51
|
+
const revoke = async (invitationId: string): Promise<void> => {
|
|
52
|
+
setPending(`revoke:${invitationId}`); setError("");
|
|
53
|
+
try { await requestJson(`/v1/admin/people/invitations/${encodeURIComponent(invitationId)}`, { method: "DELETE" }); await load(); }
|
|
54
|
+
catch (cause) { setError(errorMessage(cause, "The invitation could not be revoked.")); }
|
|
55
|
+
finally { setPending(""); }
|
|
56
|
+
};
|
|
57
|
+
const reset = async (personId: string): Promise<void> => {
|
|
58
|
+
setPending(`reset:${personId}`); setError(""); setResetMessage("");
|
|
59
|
+
try {
|
|
60
|
+
const result = await postJson<PasswordResetResponse>(`/v1/admin/people/${encodeURIComponent(personId)}/password-reset`, {});
|
|
61
|
+
setResetMessage(`Password reset instructions were sent to ${result.password_reset.email}.`);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) { setError(errorMessage(cause, "The password reset email could not be sent.")); }
|
|
64
|
+
finally { setPending(""); }
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="page-stack" data-react-slice="people-workspace">
|
|
69
|
+
<PageHeader titleId="people-title" title="People and roles" description="Invite teammates and manage workspace access.">
|
|
70
|
+
<div className="surface-buttons"><button className="secondary" type="button" disabled={loading} onClick={() => { setLoading(true); void load(); }}>Refresh</button></div>
|
|
71
|
+
</PageHeader>
|
|
72
|
+
<section className="surface people-surface" aria-labelledby="people-title">
|
|
73
|
+
<form className="invite-form" onSubmit={invite}><label>Email<input type="email" autoComplete="off" required value={email} onChange={(event) => setEmail(event.currentTarget.value)} /></label><label>Role<select value={role} onChange={(event) => setRole(event.currentTarget.value)}><option value="reviewer">Analyst</option><option value="appeal_reviewer">Appeal analyst</option><option value="auditor">Auditor</option>{isOwner ? <option value="administrator">Administrator</option> : null}</select></label><button className="primary" type="submit" disabled={pending === "invite"}>{pending === "invite" ? "Sending…" : "Send invitation"}</button></form>
|
|
74
|
+
{inviteUrl ? <div className="invite-result"><div><strong>Invitation sent</strong><small>The email contains this single-use link. You can also share it securely; it expires in seven days.</small></div><input value={inviteUrl} readOnly aria-label="Invitation link" /><button className="secondary" type="button" onClick={() => void copy(inviteUrl)}>Copy link</button></div> : null}
|
|
75
|
+
{resetMessage ? <p className="form-message" role="status">{resetMessage}</p> : null}
|
|
76
|
+
<div className="people-columns"><section><h2>Team</h2><div className="people-list" aria-busy={loading}>{loading ? <div className="skeleton-block" /> : people.length ? people.map((person) => { const privileged = person.roles.some((item) => item === "owner" || item === "administrator"); const canReset = isOwner || !privileged; return <article key={person.id}><AnalystIdentity analyst={{ displayName: person.displayName, avatarUrl: person.avatarUrl ?? null, avatarColor: "#315d4d" }} detail={person.email} /><span className="people-role">{person.roles.map(roleName).join(", ")}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <button className="secondary compact-button" type="button" disabled={pending === `reset:${person.id}`} onClick={() => void reset(person.id)}>{pending === `reset:${person.id}` ? "Sending…" : "Send password reset"}</button> : null}</article>; }) : <p className="people-empty">No team members found.</p>}</div></section><section><h2>Invitations</h2><div className="people-list">{invitations.length ? invitations.map((invitation) => <article key={invitation.id}><div><strong>{invitation.email}</strong><small>{roleName(invitation.role)} · {invitation.status}</small></div>{invitation.status === "pending" ? <button className="secondary compact-button" type="button" disabled={pending === `revoke:${invitation.id}`} onClick={() => void revoke(invitation.id)}>{pending === `revoke:${invitation.id}` ? "Revoking…" : "Revoke"}</button> : null}</article>) : <p className="people-empty">No pending invitations.</p>}</div></section></div>
|
|
77
|
+
<p className="error surface-error" role="alert">{error}</p>
|
|
78
|
+
</section>
|
|
79
|
+
</div>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface WorkspacePerson {
|
|
2
|
+
id: string;
|
|
3
|
+
displayName: string;
|
|
4
|
+
email: string;
|
|
5
|
+
avatarUrl?: string | null;
|
|
6
|
+
roles: string[];
|
|
7
|
+
lastLoginAt?: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WorkspaceInvitation {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
role: string;
|
|
14
|
+
status: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface PeopleResponse {
|
|
18
|
+
users: WorkspacePerson[];
|
|
19
|
+
invitations: WorkspaceInvitation[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface InvitationResponse { invitation: { inviteUrl: string } }
|
|
23
|
+
export interface PasswordResetResponse {
|
|
24
|
+
password_reset: { delivery: "email"; email: string };
|
|
25
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useEffect, useState, useSyncExternalStore, type ChangeEvent, type FormEvent } from "react";
|
|
2
|
+
import { AnalystAvatar } from "../components/AnalystIdentity";
|
|
3
|
+
import { PageHeader } from "../components/PageHeader";
|
|
4
|
+
import { errorMessage, putBinary, requestJson } from "../lib/http";
|
|
5
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
6
|
+
import type { ConsoleSession } from "../shell/types";
|
|
7
|
+
import { dispatchProfileUpdated } from "./events";
|
|
8
|
+
import type { ProfileResponse } from "./types";
|
|
9
|
+
|
|
10
|
+
const maximumPictureBytes = 2 * 1024 * 1024;
|
|
11
|
+
const acceptedPictureTypes = new Set(["image/jpeg", "image/png", "image/webp"]);
|
|
12
|
+
const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
|
|
13
|
+
const roleName = (role: string): string => roleNames[role] ?? role.replaceAll("_", " ");
|
|
14
|
+
|
|
15
|
+
function accountSession(session: ConsoleSession | null): ConsoleSession | null {
|
|
16
|
+
return session?.actor.type === "operator_user" ? session : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function ProfileWorkspace() {
|
|
20
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
21
|
+
const profile = accountSession(shell.session);
|
|
22
|
+
const [displayName, setDisplayName] = useState("");
|
|
23
|
+
const [pending, setPending] = useState<"save" | "upload" | "remove" | "">("");
|
|
24
|
+
const [message, setMessage] = useState("");
|
|
25
|
+
const [error, setError] = useState("");
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
setDisplayName(profile?.displayName ?? "");
|
|
29
|
+
}, [profile?.actor.id, profile?.displayName]);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
setMessage("");
|
|
33
|
+
setError("");
|
|
34
|
+
}, [profile?.actor.id]);
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
const receiveAccountResult = (event: Event): void => {
|
|
38
|
+
if (!(event instanceof CustomEvent) || typeof event.detail?.profileMessage !== "string") return;
|
|
39
|
+
setMessage("");
|
|
40
|
+
setError(event.detail.profileMessage);
|
|
41
|
+
};
|
|
42
|
+
window.addEventListener("safest:auth:authenticated", receiveAccountResult);
|
|
43
|
+
return () => window.removeEventListener("safest:auth:authenticated", receiveAccountResult);
|
|
44
|
+
}, []);
|
|
45
|
+
|
|
46
|
+
const begin = (action: "save" | "upload" | "remove"): void => {
|
|
47
|
+
setPending(action);
|
|
48
|
+
setMessage("");
|
|
49
|
+
setError("");
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const finish = (result: ProfileResponse, successMessage: string): void => {
|
|
53
|
+
dispatchProfileUpdated(result.profile);
|
|
54
|
+
setMessage(successMessage);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const save = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
if (!profile) return;
|
|
60
|
+
begin("save");
|
|
61
|
+
try {
|
|
62
|
+
const result = await requestJson<ProfileResponse>("/v1/account/profile", {
|
|
63
|
+
method: "PATCH",
|
|
64
|
+
headers: { "content-type": "application/json" },
|
|
65
|
+
body: JSON.stringify({ display_name: displayName }),
|
|
66
|
+
});
|
|
67
|
+
finish(result, "Profile saved.");
|
|
68
|
+
} catch (cause) {
|
|
69
|
+
setError(errorMessage(cause, "The profile could not be saved."));
|
|
70
|
+
} finally {
|
|
71
|
+
setPending("");
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const upload = async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
|
76
|
+
const file = event.currentTarget.files?.[0];
|
|
77
|
+
event.currentTarget.value = "";
|
|
78
|
+
if (!file || !profile) return;
|
|
79
|
+
setMessage("");
|
|
80
|
+
setError("");
|
|
81
|
+
if (file.size > maximumPictureBytes) {
|
|
82
|
+
setError("Profile pictures must be 2 MB or smaller.");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (!acceptedPictureTypes.has(file.type)) {
|
|
86
|
+
setError("Use a JPEG, PNG, or WebP profile picture.");
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
begin("upload");
|
|
90
|
+
try {
|
|
91
|
+
const result = await putBinary<ProfileResponse>("/v1/account/profile-picture", file, file.type);
|
|
92
|
+
finish(result, "Profile picture updated.");
|
|
93
|
+
} catch (cause) {
|
|
94
|
+
setError(errorMessage(cause, "The profile picture could not be uploaded."));
|
|
95
|
+
} finally {
|
|
96
|
+
setPending("");
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const remove = async (): Promise<void> => {
|
|
101
|
+
if (!profile) return;
|
|
102
|
+
begin("remove");
|
|
103
|
+
try {
|
|
104
|
+
const result = await requestJson<ProfileResponse>("/v1/account/profile-picture", { method: "DELETE" });
|
|
105
|
+
finish(result, "Profile picture removed.");
|
|
106
|
+
} catch (cause) {
|
|
107
|
+
setError(errorMessage(cause, "The profile picture could not be removed."));
|
|
108
|
+
} finally {
|
|
109
|
+
setPending("");
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return (
|
|
114
|
+
<div className="page-stack" data-react-slice="profile-workspace">
|
|
115
|
+
<PageHeader titleId="profile-title" title="My profile" description="Update how you appear in reviews and audit history." />
|
|
116
|
+
<section className="surface profile-surface" aria-labelledby="profile-title">
|
|
117
|
+
{profile ? <div className="profile-layout">
|
|
118
|
+
<div className="profile-photo-card">
|
|
119
|
+
<div className="profile-avatar-preview" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
|
|
120
|
+
<label className={`secondary upload-button${pending ? " disabled" : ""}`} aria-disabled={Boolean(pending)}>{pending === "upload" ? "Uploading…" : "Choose picture"}<input type="file" accept="image/jpeg,image/png,image/webp" disabled={Boolean(pending)} onChange={(event) => void upload(event)} /></label>
|
|
121
|
+
{profile.avatarUrl ? <button className="text-button" type="button" disabled={Boolean(pending)} onClick={() => void remove()}>{pending === "remove" ? "Removing…" : "Remove picture"}</button> : null}
|
|
122
|
+
<small>JPEG, PNG, or WebP. Maximum 2 MB.</small>
|
|
123
|
+
</div>
|
|
124
|
+
<form className="profile-form" onSubmit={save}>
|
|
125
|
+
<label>Full name<input autoComplete="name" maxLength={100} required value={displayName} onChange={(event) => setDisplayName(event.currentTarget.value)} /></label>
|
|
126
|
+
<label>Work email<input type="email" readOnly value={profile.email ?? ""} /></label>
|
|
127
|
+
<label>Workspace role<input readOnly value={profile.roles.map(roleName).join(", ")} /></label>
|
|
128
|
+
<button className="primary" type="submit" disabled={Boolean(pending) || displayName.trim() === profile.displayName}>{pending === "save" ? "Saving…" : "Save profile"}</button>
|
|
129
|
+
</form>
|
|
130
|
+
</div> : <div className="empty-product"><h2>Profile unavailable</h2><p>Sign in with a workspace account to manage a personal profile.</p></div>}
|
|
131
|
+
<p className="form-message" role="status">{message}</p>
|
|
132
|
+
<p className="error surface-error" role="alert">{error}</p>
|
|
133
|
+
</section>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ConsoleSession } from "../shell/types";
|
|
2
|
+
|
|
3
|
+
export const profileEvents = {
|
|
4
|
+
updated: "safest:profile:updated",
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export function dispatchProfileUpdated(profile: ConsoleSession): void {
|
|
8
|
+
window.dispatchEvent(new CustomEvent(profileEvents.updated, { detail: { profile } }));
|
|
9
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
|
|
2
|
+
import { readable, relativeTime } from "../lib/format";
|
|
3
|
+
import { PageHeader } from "../components/PageHeader";
|
|
4
|
+
import { dispatchClaimQualitySample, dispatchCloseQualitySample, dispatchOpenQualitySample, dispatchQualityRefresh, dispatchQualityReview } from "./events";
|
|
5
|
+
import { getQualityWorkspaceSnapshot, subscribeToQualityWorkspace } from "./store";
|
|
6
|
+
import type { QualityIssueCode, QualitySampleSummary } from "./types";
|
|
7
|
+
|
|
8
|
+
function QualityRow({ sample }: { sample: QualitySampleSummary }) {
|
|
9
|
+
const status = sample.state === "complete" ? sample.agrees ? "agreed" : "disagreed" : sample.state;
|
|
10
|
+
return <article><div><strong>{sample.reportReference}</strong><small>{readable(sample.sampleReason)} · {readable(sample.aiOutcome || sample.aiRunStatus)} · {relativeTime(sample.selectedAt)}</small></div><span className={`badge${sample.agrees === false ? " urgent" : ""}`}>{readable(status)}</span><button className="secondary" type="button" onClick={() => dispatchOpenQualitySample(sample.id)}>{sample.state === "complete" ? "Inspect" : "Review"}</button></article>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function Fact({ label, children }: { label: string; children: ReactNode }) {
|
|
14
|
+
return <><dt>{label}</dt><dd>{children || "—"}</dd></>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function QualityWorkspace() {
|
|
18
|
+
const snapshot = useSyncExternalStore(subscribeToQualityWorkspace, getQualityWorkspaceSnapshot, getQualityWorkspaceSnapshot);
|
|
19
|
+
const pending = snapshot.samples.filter((sample) => sample.state !== "complete").length;
|
|
20
|
+
return (
|
|
21
|
+
<div className="page-stack" data-react-slice="quality-workspace">
|
|
22
|
+
<PageHeader titleId="quality-title" title="AI quality" description="Compare sampled AI results with human review.">
|
|
23
|
+
<div className="surface-buttons"><button className="secondary" type="button" disabled={snapshot.refreshing || !snapshot.canRead} onClick={dispatchQualityRefresh}>{snapshot.refreshing ? "Refreshing…" : "Refresh"}</button></div>
|
|
24
|
+
</PageHeader>
|
|
25
|
+
<section className="surface quality-surface" aria-labelledby="quality-title">
|
|
26
|
+
<div className="quality-summary"><p>People review random autonomous decisions and shadow comparisons. A disagreement reopens the report and can pause the queue.</p><strong>{snapshot.loading ? "— pending" : `${pending} pending`}</strong></div>
|
|
27
|
+
<div className="quality-list" aria-busy={snapshot.loading || snapshot.refreshing}>{snapshot.loading ? <div className="skeleton-block" role="status" aria-label="Loading AI quality samples" /> : snapshot.samples.length ? snapshot.samples.map((sample) => <QualityRow sample={sample} key={sample.id} />) : <div className="empty compact"><h3>No quality reviews yet</h3><p>Reviews appear after AI decisions or shadow comparisons.</p></div>}</div>
|
|
28
|
+
<p className="error surface-error" role="alert">{snapshot.error}</p>
|
|
29
|
+
</section>
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function QualityDialog() {
|
|
35
|
+
const snapshot = useSyncExternalStore(subscribeToQualityWorkspace, getQualityWorkspaceSnapshot, getQualityWorkspaceSnapshot);
|
|
36
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
37
|
+
const record = snapshot.detail.record;
|
|
38
|
+
const sample = record?.sample;
|
|
39
|
+
const report = record?.report;
|
|
40
|
+
const [agrees, setAgrees] = useState(true);
|
|
41
|
+
const [issueCode, setIssueCode] = useState<QualityIssueCode>("");
|
|
42
|
+
const [correctedOutcomeCode, setCorrectedOutcomeCode] = useState("");
|
|
43
|
+
const [correctedPolicyCode, setCorrectedPolicyCode] = useState("");
|
|
44
|
+
const [notes, setNotes] = useState("");
|
|
45
|
+
const complete = sample?.state === "complete";
|
|
46
|
+
const activeClaim = Boolean(sample?.claimedBy && sample.claimExpiresAt && Date.parse(sample.claimExpiresAt) > Date.parse(snapshot.generatedAt));
|
|
47
|
+
const mine = Boolean(activeClaim && sample?.claimedBy === snapshot.actorId);
|
|
48
|
+
const disabled = Boolean(complete || !mine || snapshot.detail.pendingAction === "review");
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
const dialog = dialogRef.current;
|
|
52
|
+
if (!dialog) return;
|
|
53
|
+
if (snapshot.detail.sampleId && !dialog.open) dialog.showModal();
|
|
54
|
+
if (!snapshot.detail.sampleId && dialog.open) dialog.close();
|
|
55
|
+
}, [snapshot.detail.sampleId]);
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
setAgrees(sample?.agrees ?? true);
|
|
58
|
+
setIssueCode((sample?.issueCodes?.[0] as QualityIssueCode | undefined) ?? "");
|
|
59
|
+
setCorrectedOutcomeCode(sample?.correctedOutcomeCode ?? "");
|
|
60
|
+
setCorrectedPolicyCode(sample?.correctedPolicyCode ?? "");
|
|
61
|
+
setNotes(sample?.qualityNotes ?? "");
|
|
62
|
+
}, [snapshot.detail.sampleId, complete]);
|
|
63
|
+
|
|
64
|
+
const submit = (event: FormEvent<HTMLFormElement>): void => {
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
dispatchQualityReview({ agrees, issueCode: agrees ? "" : issueCode, correctedOutcomeCode, correctedPolicyCode, notes });
|
|
67
|
+
};
|
|
68
|
+
const reportDetails = report?.answers.find((answer) => answer.fieldKey === "details")?.value;
|
|
69
|
+
const humanDecision = report?.decisions.find((decision) => decision.makerType === "human");
|
|
70
|
+
const claimMessage = complete
|
|
71
|
+
? `Reviewed by ${sample?.qualityReviewerId || "a human reviewer"}: ${sample?.agrees ? "agreed" : "disagreed"}.`
|
|
72
|
+
: mine && sample?.claimExpiresAt
|
|
73
|
+
? `Claimed by you until ${new Date(sample.claimExpiresAt).toLocaleTimeString()}.`
|
|
74
|
+
: activeClaim && sample?.claimExpiresAt
|
|
75
|
+
? `Claimed by another reviewer until ${new Date(sample.claimExpiresAt).toLocaleTimeString()}.`
|
|
76
|
+
: "Claim this sample before recording a review.";
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<dialog ref={dialogRef} className="detail-dialog quality-dialog" aria-labelledby="quality-reference" onCancel={(event) => { event.preventDefault(); dispatchCloseQualitySample(); }} data-react-slice="quality-dialog">
|
|
80
|
+
<div className="detail-head"><div><h2 id="quality-reference">{sample?.reportReference || "AI sample"}</h2><p>{sample ? `${readable(sample.sampleReason)} · ${readable(sample.state)}` : snapshot.detail.loading ? "Loading sample…" : "AI quality review"}</p></div><button className="icon-button" type="button" aria-label="Close quality sample" onClick={dispatchCloseQualitySample}>×</button></div>
|
|
81
|
+
<div className="quality-detail-content">
|
|
82
|
+
{snapshot.detail.loading && !record ? <div className="skeleton-block" role="status" aria-label="Loading AI quality sample" /> : record && sample && report ? <><div className="detail-grid"><section><h3>Case and comparison</h3><dl><Fact label="Allegation">{readable(report.reasonCode)}</Fact><Fact label="Target">{readable(report.targetType)} · {report.targetReference}</Fact><Fact label="Report details">{typeof reportDetails === "string" ? reportDetails : "—"}</Fact><Fact label="AI outcome">{readable(sample.aiOutcome || "No outcome")} · {sample.aiPolicyCode || "no policy"}</Fact><Fact label="Human outcome">{humanDecision ? `${readable(humanDecision.decisionCode)} · ${humanDecision.policyCode || "no policy"}` : "Not recorded"}</Fact></dl></section><section><h3>AI provenance</h3><dl><Fact label="Configuration">{sample.configVersionId}</Fact><Fact label="Model">{sample.model}</Fact><Fact label="Prompt">{sample.promptVersion}</Fact><Fact label="Policy version">{sample.policyVersionId}</Fact><Fact label="Template">{sample.messageTemplateId || "None"}</Fact><Fact label="Evidence">{sample.aiEvidenceReferences?.join(", ") || "None"}</Fact></dl></section></div><section><h3>Validated AI output</h3><pre>{JSON.stringify(sample.rawOutput, null, 2)}</pre></section><div className="claim-bar"><p>{claimMessage}</p>{!complete && !activeClaim && snapshot.canDecide ? <button className="primary" type="button" disabled={snapshot.detail.pendingAction === "claim"} onClick={dispatchClaimQualitySample}>{snapshot.detail.pendingAction === "claim" ? "Claiming…" : "Claim sample"}</button> : null}</div><form className="quality-review-form" onSubmit={submit}><h3>Record comparison</h3><label>Assessment<select value={agrees ? "true" : "false"} disabled={disabled} onChange={(event) => { const next = event.currentTarget.value === "true"; setAgrees(next); if (next) setIssueCode(""); }}><option value="true">AI result agrees with human review</option><option value="false">AI result disagrees</option></select></label><label>Issue when disagreeing<select value={issueCode} required={!agrees} disabled={disabled || agrees} onChange={(event) => setIssueCode(event.currentTarget.value as QualityIssueCode)}><option value="">Select an issue</option><option value="incorrect_outcome">Incorrect outcome</option><option value="incorrect_policy_code">Incorrect policy code</option><option value="unsupported_evidence">Unsupported evidence</option><option value="unsafe_or_inaccurate_message">Unsafe or inaccurate message</option><option value="should_have_escalated">Should have escalated</option><option value="other">Other</option></select></label><label>Corrected outcome <small>Optional structured comparison.</small><input maxLength={100} value={correctedOutcomeCode} disabled={disabled} onChange={(event) => setCorrectedOutcomeCode(event.currentTarget.value)} /></label><label>Corrected policy code <small>Optional structured comparison.</small><input maxLength={100} value={correctedPolicyCode} disabled={disabled} onChange={(event) => setCorrectedPolicyCode(event.currentTarget.value)} /></label><label className="quality-notes">Review notes<textarea maxLength={4000} minLength={agrees ? undefined : 10} required={!agrees} value={notes} disabled={disabled} onChange={(event) => setNotes(event.currentTarget.value)} /></label><button className="primary" type="submit" disabled={disabled}>{snapshot.detail.pendingAction === "review" ? "Completing…" : "Complete quality review"}</button></form></> : null}
|
|
83
|
+
<p className="error" role="alert">{snapshot.detail.error}</p>
|
|
84
|
+
</div>
|
|
85
|
+
</dialog>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
2
|
+
import { dispatchReportRefresh } from "../reports/events";
|
|
3
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
4
|
+
import { qualityEvents } from "./events";
|
|
5
|
+
import { getQualityWorkspaceSnapshot, setQualityWorkspaceSnapshot } from "./store";
|
|
6
|
+
import type { QualityDetailRecord, QualityReviewInput, QualitySampleSummary, QualityWorkspaceSnapshot } from "./types";
|
|
7
|
+
|
|
8
|
+
interface QualityListResponse { samples: QualitySampleSummary[] }
|
|
9
|
+
|
|
10
|
+
const initialSnapshot = (): QualityWorkspaceSnapshot => ({
|
|
11
|
+
loading: true,
|
|
12
|
+
refreshing: false,
|
|
13
|
+
error: "",
|
|
14
|
+
actorId: "",
|
|
15
|
+
generatedAt: new Date().toISOString(),
|
|
16
|
+
canRead: false,
|
|
17
|
+
canDecide: false,
|
|
18
|
+
samples: [],
|
|
19
|
+
detail: { sampleId: null, loading: false, error: "", pendingAction: null, record: null },
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
let actorId = "";
|
|
23
|
+
let requestVersion = 0;
|
|
24
|
+
let started = false;
|
|
25
|
+
|
|
26
|
+
function permissions(snapshot: QualityWorkspaceSnapshot): QualityWorkspaceSnapshot {
|
|
27
|
+
const session = getShellSnapshot().session;
|
|
28
|
+
return {
|
|
29
|
+
...snapshot,
|
|
30
|
+
actorId: session?.actor.id || "",
|
|
31
|
+
canRead: session?.permissions.readReports === true,
|
|
32
|
+
canDecide: session?.permissions.decideReports === true,
|
|
33
|
+
generatedAt: new Date().toISOString(),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function loadSamples(): Promise<void> {
|
|
38
|
+
const session = getShellSnapshot().session;
|
|
39
|
+
if (!session?.permissions.readReports) {
|
|
40
|
+
setQualityWorkspaceSnapshot(permissions({ ...initialSnapshot(), loading: false }));
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const current = getQualityWorkspaceSnapshot();
|
|
44
|
+
setQualityWorkspaceSnapshot(permissions({ ...current, error: "", refreshing: !current.loading }));
|
|
45
|
+
try {
|
|
46
|
+
const result = await requestJson<QualityListResponse>("/v1/admin/ai/quality-samples?limit=50");
|
|
47
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, samples: result.samples || [] }));
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
setQualityWorkspaceSnapshot((snapshot) => ({ ...snapshot, error: errorMessage(cause, "AI quality samples could not be loaded.") }));
|
|
50
|
+
} finally {
|
|
51
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, loading: false, refreshing: false }));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function openSample(sampleId: string): Promise<void> {
|
|
56
|
+
const version = ++requestVersion;
|
|
57
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({
|
|
58
|
+
...snapshot,
|
|
59
|
+
detail: {
|
|
60
|
+
...snapshot.detail,
|
|
61
|
+
sampleId,
|
|
62
|
+
loading: true,
|
|
63
|
+
error: "",
|
|
64
|
+
record: snapshot.detail.record?.sample.id === sampleId ? snapshot.detail.record : null,
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
try {
|
|
68
|
+
const record = await requestJson<QualityDetailRecord>(`/v1/admin/ai/quality-samples/${encodeURIComponent(sampleId)}`);
|
|
69
|
+
if (version !== requestVersion || getQualityWorkspaceSnapshot().detail.sampleId !== sampleId) return;
|
|
70
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { ...snapshot.detail, record } }));
|
|
71
|
+
} catch (cause) {
|
|
72
|
+
if (version !== requestVersion || getQualityWorkspaceSnapshot().detail.sampleId !== sampleId) return;
|
|
73
|
+
setQualityWorkspaceSnapshot((snapshot) => ({ ...snapshot, detail: { ...snapshot.detail, error: errorMessage(cause, "The AI quality sample could not be opened.") } }));
|
|
74
|
+
} finally {
|
|
75
|
+
if (version === requestVersion && getQualityWorkspaceSnapshot().detail.sampleId === sampleId) {
|
|
76
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { ...snapshot.detail, loading: false } }));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function closeSample(): void {
|
|
82
|
+
requestVersion += 1;
|
|
83
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions({ ...snapshot, detail: { sampleId: null, loading: false, error: "", pendingAction: null, record: null } }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function claimSample(): Promise<void> {
|
|
87
|
+
const snapshot = getQualityWorkspaceSnapshot();
|
|
88
|
+
const sample = snapshot.detail.record?.sample;
|
|
89
|
+
if (!sample || !snapshot.canDecide || snapshot.detail.pendingAction) return;
|
|
90
|
+
setQualityWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: "claim" } });
|
|
91
|
+
try {
|
|
92
|
+
await requestJson(`/v1/admin/ai/quality-samples/${encodeURIComponent(sample.id)}/claim`, { method: "POST" });
|
|
93
|
+
await openSample(sample.id);
|
|
94
|
+
} catch (cause) {
|
|
95
|
+
setQualityWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The AI quality sample could not be claimed.") } }));
|
|
96
|
+
} finally {
|
|
97
|
+
setQualityWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function recordReview(input: QualityReviewInput): Promise<void> {
|
|
102
|
+
const snapshot = getQualityWorkspaceSnapshot();
|
|
103
|
+
const sample = snapshot.detail.record?.sample;
|
|
104
|
+
const mine = Boolean(sample?.claimedBy === snapshot.actorId && sample.claimExpiresAt && Date.parse(sample.claimExpiresAt) > Date.now());
|
|
105
|
+
if (!sample || !snapshot.canDecide || snapshot.detail.pendingAction || !mine || sample.state === "complete") return;
|
|
106
|
+
setQualityWorkspaceSnapshot({ ...snapshot, detail: { ...snapshot.detail, error: "", pendingAction: "review" } });
|
|
107
|
+
try {
|
|
108
|
+
await requestJson(`/v1/admin/ai/quality-samples/${encodeURIComponent(sample.id)}/review`, {
|
|
109
|
+
method: "POST",
|
|
110
|
+
headers: mutationHeaders(),
|
|
111
|
+
body: JSON.stringify({
|
|
112
|
+
agrees: input.agrees,
|
|
113
|
+
issue_codes: input.agrees || !input.issueCode ? [] : [input.issueCode],
|
|
114
|
+
corrected_outcome_code: input.correctedOutcomeCode || null,
|
|
115
|
+
corrected_policy_code: input.correctedPolicyCode || null,
|
|
116
|
+
notes: input.notes,
|
|
117
|
+
}),
|
|
118
|
+
});
|
|
119
|
+
closeSample();
|
|
120
|
+
await loadSamples();
|
|
121
|
+
dispatchReportRefresh();
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
setQualityWorkspaceSnapshot((current) => ({ ...current, detail: { ...current.detail, error: errorMessage(cause, "The AI quality review could not be recorded.") } }));
|
|
124
|
+
} finally {
|
|
125
|
+
setQualityWorkspaceSnapshot((current) => permissions({ ...current, detail: { ...current.detail, pendingAction: null } }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function startQualityController(): void {
|
|
130
|
+
if (started) return;
|
|
131
|
+
started = true;
|
|
132
|
+
subscribeToShell(() => {
|
|
133
|
+
const session = getShellSnapshot().session;
|
|
134
|
+
const nextActorId = session?.actor.id || "";
|
|
135
|
+
if (nextActorId === actorId) {
|
|
136
|
+
setQualityWorkspaceSnapshot((snapshot) => permissions(snapshot));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
actorId = nextActorId;
|
|
140
|
+
requestVersion += 1;
|
|
141
|
+
setQualityWorkspaceSnapshot(initialSnapshot());
|
|
142
|
+
if (session) void loadSamples();
|
|
143
|
+
});
|
|
144
|
+
window.addEventListener(qualityEvents.refresh, () => { void loadSamples(); });
|
|
145
|
+
window.addEventListener(qualityEvents.open, (event) => {
|
|
146
|
+
if (event instanceof CustomEvent && typeof event.detail?.sampleId === "string") void openSample(event.detail.sampleId);
|
|
147
|
+
});
|
|
148
|
+
window.addEventListener(qualityEvents.close, closeSample);
|
|
149
|
+
window.addEventListener(qualityEvents.claim, () => { void claimSample(); });
|
|
150
|
+
window.addEventListener(qualityEvents.review, (event) => {
|
|
151
|
+
if (!(event instanceof CustomEvent) || typeof event.detail?.agrees !== "boolean" || typeof event.detail?.issueCode !== "string" || typeof event.detail?.notes !== "string") return;
|
|
152
|
+
void recordReview(event.detail as QualityReviewInput);
|
|
153
|
+
});
|
|
154
|
+
}
|