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,78 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
type Provider = {
|
|
4
|
+
providerId: "google" | "github" | "cloudflare";
|
|
5
|
+
displayName: string;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
interface OAuthButtonsProps {
|
|
9
|
+
disabled?: boolean;
|
|
10
|
+
onError: (message: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function OAuthButtons({ disabled = false, onError }: OAuthButtonsProps) {
|
|
14
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
15
|
+
const [pending, setPending] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
let active = true;
|
|
19
|
+
void fetch("/v1/auth/providers", { credentials: "same-origin" })
|
|
20
|
+
.then(async (response) => response.ok ? response.json() : Promise.reject(new Error("Provider discovery failed.")))
|
|
21
|
+
.then((body: { providers?: Provider[] }) => {
|
|
22
|
+
if (active && Array.isArray(body.providers)) setProviders(body.providers);
|
|
23
|
+
})
|
|
24
|
+
.catch(() => {
|
|
25
|
+
if (active) setProviders([]);
|
|
26
|
+
});
|
|
27
|
+
return () => { active = false; };
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
async function signIn(provider: Provider) {
|
|
31
|
+
if (disabled || pending) return;
|
|
32
|
+
onError("");
|
|
33
|
+
setPending(provider.providerId);
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch("/api/auth/sign-in/social", {
|
|
36
|
+
method: "POST",
|
|
37
|
+
credentials: "same-origin",
|
|
38
|
+
headers: { "content-type": "application/json" },
|
|
39
|
+
body: JSON.stringify({
|
|
40
|
+
provider: provider.providerId,
|
|
41
|
+
callbackURL: `${window.location.origin}/`,
|
|
42
|
+
newUserCallbackURL: `${window.location.origin}/`,
|
|
43
|
+
errorCallbackURL: `${window.location.origin}/`,
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const body = await response.json().catch(() => ({})) as { url?: unknown; message?: unknown };
|
|
47
|
+
if (!response.ok || typeof body.url !== "string") {
|
|
48
|
+
throw new Error(typeof body.message === "string" ? body.message : `Could not continue with ${provider.displayName}.`);
|
|
49
|
+
}
|
|
50
|
+
sessionStorage.removeItem("safest-reports-force-login");
|
|
51
|
+
window.location.assign(body.url);
|
|
52
|
+
} catch (error) {
|
|
53
|
+
setPending(null);
|
|
54
|
+
onError(error instanceof Error ? error.message : `Could not continue with ${provider.displayName}.`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (!providers.length) return null;
|
|
59
|
+
return (
|
|
60
|
+
<div className="oauth-sign-in" aria-label="Single sign-on options">
|
|
61
|
+
<div className="oauth-buttons">
|
|
62
|
+
{providers.map((provider) => (
|
|
63
|
+
<button
|
|
64
|
+
key={provider.providerId}
|
|
65
|
+
className="secondary wide"
|
|
66
|
+
type="button"
|
|
67
|
+
data-auth-provider={provider.providerId}
|
|
68
|
+
disabled={disabled || pending !== null}
|
|
69
|
+
onClick={() => { void signIn(provider); }}
|
|
70
|
+
>
|
|
71
|
+
{pending === provider.providerId ? "Opening…" : `Continue with ${provider.displayName}`}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
<div className="auth-divider" role="separator"><span>or use your password</span></div>
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { useEffect, useState, type FormEvent, type RefObject } from "react";
|
|
2
|
+
import { authEvents, dispatchAuthEvent } from "./events";
|
|
3
|
+
import { errorMessage, postJson } from "../lib/http";
|
|
4
|
+
import { OAuthButtons } from "./OAuthButtons";
|
|
5
|
+
|
|
6
|
+
interface OperatorLoginProps {
|
|
7
|
+
email: string;
|
|
8
|
+
emailInput: RefObject<HTMLInputElement | null>;
|
|
9
|
+
onEmailChange: (value: string) => void;
|
|
10
|
+
onForgotPassword: () => void;
|
|
11
|
+
onError: (message: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function OperatorLogin({ email, emailInput, onEmailChange, onForgotPassword, onError }: OperatorLoginProps) {
|
|
15
|
+
const [password, setPassword] = useState("");
|
|
16
|
+
const [pending, setPending] = useState(false);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const resetPendingAction = (): void => {
|
|
20
|
+
setPending(false);
|
|
21
|
+
};
|
|
22
|
+
window.addEventListener(authEvents.error, resetPendingAction);
|
|
23
|
+
window.addEventListener(authEvents.focusEmail, resetPendingAction);
|
|
24
|
+
return () => {
|
|
25
|
+
window.removeEventListener(authEvents.error, resetPendingAction);
|
|
26
|
+
window.removeEventListener(authEvents.focusEmail, resetPendingAction);
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
29
|
+
|
|
30
|
+
async function signIn(event: FormEvent<HTMLFormElement>) {
|
|
31
|
+
event.preventDefault();
|
|
32
|
+
if (pending) return;
|
|
33
|
+
onError("");
|
|
34
|
+
setPending(true);
|
|
35
|
+
try {
|
|
36
|
+
await postJson("/v1/auth/login", { email: email.trim(), password });
|
|
37
|
+
setPassword("");
|
|
38
|
+
sessionStorage.removeItem("safest-reports-force-login");
|
|
39
|
+
dispatchAuthEvent(authEvents.authenticated);
|
|
40
|
+
} catch (caught) {
|
|
41
|
+
setPending(false);
|
|
42
|
+
onError(errorMessage(caught, "Sign in failed."));
|
|
43
|
+
emailInput.current?.focus();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function requestPasswordReset() {
|
|
48
|
+
onError("");
|
|
49
|
+
setPassword("");
|
|
50
|
+
onForgotPassword();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<div className="auth-panel" data-auth-view="login" data-react-slice="operator-login">
|
|
55
|
+
<p className="eyebrow">Private safety workspace</p>
|
|
56
|
+
<h2 id="login-title">Sign in to Safest</h2>
|
|
57
|
+
<p id="operator-login-help" className="auth-copy">
|
|
58
|
+
Admins and analysts use their invited Safest account. They do not need a Cloudflare account.
|
|
59
|
+
</p>
|
|
60
|
+
<OAuthButtons disabled={pending} onError={onError} />
|
|
61
|
+
<form id="operator-login-form" aria-busy={pending} onSubmit={signIn}>
|
|
62
|
+
<label htmlFor="operator-email">Work email</label>
|
|
63
|
+
<input
|
|
64
|
+
ref={emailInput}
|
|
65
|
+
id="operator-email"
|
|
66
|
+
name="email"
|
|
67
|
+
type="email"
|
|
68
|
+
inputMode="email"
|
|
69
|
+
autoComplete="username"
|
|
70
|
+
autoCapitalize="none"
|
|
71
|
+
spellCheck={false}
|
|
72
|
+
value={email}
|
|
73
|
+
onChange={(event) => onEmailChange(event.currentTarget.value)}
|
|
74
|
+
disabled={pending}
|
|
75
|
+
required
|
|
76
|
+
autoFocus
|
|
77
|
+
/>
|
|
78
|
+
<label htmlFor="operator-password">Password</label>
|
|
79
|
+
<input
|
|
80
|
+
id="operator-password"
|
|
81
|
+
name="password"
|
|
82
|
+
type="password"
|
|
83
|
+
autoComplete="current-password"
|
|
84
|
+
value={password}
|
|
85
|
+
onChange={(event) => setPassword(event.currentTarget.value)}
|
|
86
|
+
disabled={pending}
|
|
87
|
+
required
|
|
88
|
+
/>
|
|
89
|
+
<button className="primary wide" type="submit" disabled={pending}>
|
|
90
|
+
{pending ? "Signing in…" : "Sign in"}
|
|
91
|
+
</button>
|
|
92
|
+
</form>
|
|
93
|
+
<button id="forgot-password-link" className="text-button" type="button" onClick={requestPasswordReset}>
|
|
94
|
+
Forgot password?
|
|
95
|
+
</button>
|
|
96
|
+
<details className="infrastructure-access">
|
|
97
|
+
<summary>Infrastructure owner access</summary>
|
|
98
|
+
<p>This separate route is only for the allowlisted infrastructure owner. Cloudflare Access handles authentication and recovery.</p>
|
|
99
|
+
<button
|
|
100
|
+
id="access-login"
|
|
101
|
+
className="secondary wide"
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => window.location.assign("/v1/infrastructure/bootstrap")}
|
|
104
|
+
>
|
|
105
|
+
Continue with Cloudflare Access
|
|
106
|
+
</button>
|
|
107
|
+
</details>
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { errorMessage, postJson } from "../lib/http";
|
|
3
|
+
|
|
4
|
+
interface PasswordRecoveryProps {
|
|
5
|
+
email: string;
|
|
6
|
+
onEmailChange: (value: string) => void;
|
|
7
|
+
onBack: () => void;
|
|
8
|
+
onError: (message: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface PasswordResetRequestResponse {
|
|
12
|
+
message: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function PasswordRecovery({ email, onEmailChange, onBack, onError }: PasswordRecoveryProps) {
|
|
16
|
+
const [pending, setPending] = useState(false);
|
|
17
|
+
const [message, setMessage] = useState("");
|
|
18
|
+
|
|
19
|
+
async function requestReset(event: FormEvent<HTMLFormElement>) {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
if (pending) return;
|
|
22
|
+
onError("");
|
|
23
|
+
setMessage("");
|
|
24
|
+
setPending(true);
|
|
25
|
+
try {
|
|
26
|
+
const result = await postJson<PasswordResetRequestResponse>("/v1/auth/password-resets", { email: email.trim() });
|
|
27
|
+
setMessage(result.message);
|
|
28
|
+
} catch (caught) {
|
|
29
|
+
onError(errorMessage(caught, "Password reset instructions could not be requested."));
|
|
30
|
+
} finally {
|
|
31
|
+
setPending(false);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<div className="auth-panel" data-auth-view="forgot">
|
|
37
|
+
<p className="eyebrow">Account recovery</p>
|
|
38
|
+
<h2 id="login-title">Reset your password</h2>
|
|
39
|
+
<p className="auth-copy">Enter your work email. If the account can be reset, we’ll send a single-use link through your workspace notification channel.</p>
|
|
40
|
+
<form id="forgot-password-form" aria-busy={pending} onSubmit={requestReset}>
|
|
41
|
+
<label htmlFor="forgot-password-email">Work email</label>
|
|
42
|
+
<input
|
|
43
|
+
id="forgot-password-email"
|
|
44
|
+
type="email"
|
|
45
|
+
inputMode="email"
|
|
46
|
+
autoComplete="email"
|
|
47
|
+
autoCapitalize="none"
|
|
48
|
+
spellCheck={false}
|
|
49
|
+
value={email}
|
|
50
|
+
onChange={(event) => {
|
|
51
|
+
onEmailChange(event.currentTarget.value);
|
|
52
|
+
setMessage("");
|
|
53
|
+
}}
|
|
54
|
+
disabled={pending}
|
|
55
|
+
required
|
|
56
|
+
autoFocus
|
|
57
|
+
/>
|
|
58
|
+
<button className="primary wide" type="submit" disabled={pending}>{pending ? "Sending…" : "Send reset instructions"}</button>
|
|
59
|
+
</form>
|
|
60
|
+
<button className="text-button auth-back" type="button" onClick={onBack}>Back to sign in</button>
|
|
61
|
+
<p id="forgot-password-message" className="form-message" role="status">{message}</p>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface PasswordResetProps {
|
|
67
|
+
token: string;
|
|
68
|
+
onBack: () => void;
|
|
69
|
+
onError: (message: string) => void;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function PasswordReset({ token, onBack, onError }: PasswordResetProps) {
|
|
73
|
+
const confirmInput = useRef<HTMLInputElement>(null);
|
|
74
|
+
const [password, setPassword] = useState("");
|
|
75
|
+
const [confirmation, setConfirmation] = useState("");
|
|
76
|
+
const [pending, setPending] = useState(false);
|
|
77
|
+
const [completed, setCompleted] = useState(false);
|
|
78
|
+
|
|
79
|
+
async function resetPassword(event: FormEvent<HTMLFormElement>) {
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
if (pending) return;
|
|
82
|
+
onError("");
|
|
83
|
+
if (password !== confirmation) {
|
|
84
|
+
onError("The new passwords do not match.");
|
|
85
|
+
confirmInput.current?.focus();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!token) {
|
|
89
|
+
onError("This password reset link is invalid or expired.");
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
setPending(true);
|
|
93
|
+
try {
|
|
94
|
+
await postJson("/v1/auth/password-resets/complete", { token, password });
|
|
95
|
+
setPassword("");
|
|
96
|
+
setConfirmation("");
|
|
97
|
+
setPending(false);
|
|
98
|
+
setCompleted(true);
|
|
99
|
+
} catch (caught) {
|
|
100
|
+
setPending(false);
|
|
101
|
+
onError(errorMessage(caught, "The password could not be reset."));
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (completed) {
|
|
106
|
+
return (
|
|
107
|
+
<div className="auth-panel" data-auth-view="reset-complete">
|
|
108
|
+
<p className="eyebrow">Password updated</p>
|
|
109
|
+
<h2 id="login-title">Your password is ready</h2>
|
|
110
|
+
<p className="auth-copy">Sign in with your new password. Your other sessions have been closed.</p>
|
|
111
|
+
<button className="primary wide" type="button" onClick={onBack}>Back to sign in</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="auth-panel" data-auth-view="reset">
|
|
118
|
+
<p className="eyebrow">Account recovery</p>
|
|
119
|
+
<h2 id="login-title">Choose a new password</h2>
|
|
120
|
+
<p className="auth-copy">Create a new password for this workspace. The reset link works once and expires automatically.</p>
|
|
121
|
+
<form id="password-reset-form" aria-busy={pending} onSubmit={resetPassword}>
|
|
122
|
+
<label htmlFor="password-reset-password">New password</label>
|
|
123
|
+
<input id="password-reset-password" type="password" autoComplete="new-password" minLength={12} maxLength={128} value={password} onChange={(event) => setPassword(event.currentTarget.value)} disabled={pending} required autoFocus />
|
|
124
|
+
<label htmlFor="password-reset-confirm">Confirm new password</label>
|
|
125
|
+
<input ref={confirmInput} id="password-reset-confirm" type="password" autoComplete="new-password" minLength={12} maxLength={128} value={confirmation} onChange={(event) => setConfirmation(event.currentTarget.value)} disabled={pending} required />
|
|
126
|
+
<small className="field-help">Use at least 12 characters. A passphrase works well.</small>
|
|
127
|
+
<button className="primary wide" type="submit" disabled={pending}>{pending ? "Resetting…" : "Reset password"}</button>
|
|
128
|
+
</form>
|
|
129
|
+
<button className="text-button auth-back" type="button" onClick={onBack}>Back to sign in</button>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type AccountView = "login" | "forgot" | "reset" | "invite";
|
|
2
|
+
|
|
3
|
+
export interface AccountRoute {
|
|
4
|
+
view: AccountView;
|
|
5
|
+
token: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function accountRouteFromHash(hash: string): AccountRoute {
|
|
9
|
+
const parameters = new URLSearchParams(hash.replace(/^#/u, ""));
|
|
10
|
+
const invitationToken = parameters.get("invite") || "";
|
|
11
|
+
if (invitationToken) return { view: "invite", token: invitationToken };
|
|
12
|
+
const resetToken = parameters.get("reset") || "";
|
|
13
|
+
if (resetToken) return { view: "reset", token: resetToken };
|
|
14
|
+
return { view: "login", token: "" };
|
|
15
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const authEvents = {
|
|
2
|
+
authenticated: "safest:auth:authenticated",
|
|
3
|
+
error: "safest:auth:error",
|
|
4
|
+
focusEmail: "safest:auth:focus-email",
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
export interface AuthErrorDetail {
|
|
8
|
+
message: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type AuthEventName = (typeof authEvents)[keyof typeof authEvents];
|
|
12
|
+
|
|
13
|
+
export function dispatchAuthEvent(name: AuthEventName, detail?: unknown): void {
|
|
14
|
+
window.dispatchEvent(new CustomEvent(name, { detail }));
|
|
15
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { PageHeader } from "../components/PageHeader";
|
|
3
|
+
import { getCommandSnapshot, subscribeToCommand } from "./store";
|
|
4
|
+
import type { CommandAction, CommandMetricSnapshot, CommandQueueLoad, CommandRunSummary } from "./types";
|
|
5
|
+
import { dispatchOpenReport } from "../reports/events";
|
|
6
|
+
import { dispatchNavigation } from "../shell/events";
|
|
7
|
+
|
|
8
|
+
function performAction(action: CommandAction): void {
|
|
9
|
+
if (action.type === "report") dispatchOpenReport(action.reportId);
|
|
10
|
+
else dispatchNavigation(action.view);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function formattedDate(value: string): string {
|
|
14
|
+
const date = new Date(value);
|
|
15
|
+
if (!Number.isFinite(date.getTime())) return "Today";
|
|
16
|
+
return new Intl.DateTimeFormat(undefined, { weekday: "long", day: "numeric", month: "long" }).format(date);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function greeting(value: string, firstName: string): string {
|
|
20
|
+
const hour = new Date(value).getHours();
|
|
21
|
+
const timeOfDay = hour < 12 ? "morning" : hour < 18 ? "afternoon" : "evening";
|
|
22
|
+
return `Good ${timeOfDay}, ${firstName || "there"}.`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function EmptyProduct({ icon, title, description }: { icon: string; title: string; description: string }) {
|
|
26
|
+
return <div className="empty-product"><span aria-hidden="true">{icon}</span><h2>{title}</h2><p>{description}</p></div>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Metrics({ metrics, loading }: { metrics: CommandMetricSnapshot; loading: boolean }) {
|
|
30
|
+
const values = [
|
|
31
|
+
{ label: "Open reports", detail: "Accepted allegations", icon: "i-inbox", value: metrics.openReports },
|
|
32
|
+
{ label: "Needs a person", detail: "Unclaimed or waiting", icon: "i-user", value: metrics.needsHuman },
|
|
33
|
+
{ label: "Workflow runs", detail: "Running or waiting", icon: "i-workflow", value: metrics.activeRuns },
|
|
34
|
+
{ label: "Unresolved incidents", detail: "Waiting for repair", icon: "i-activity", value: metrics.unresolvedIncidents },
|
|
35
|
+
];
|
|
36
|
+
return (
|
|
37
|
+
<section className="metrics command-metrics" aria-label="Workspace summary" aria-busy={loading}>
|
|
38
|
+
{values.map((item) => (
|
|
39
|
+
<article key={item.label}><span>{item.label} <svg aria-hidden="true"><use href={`#${item.icon}`} /></svg></span><strong>{loading ? "—" : item.value}</strong><small>{item.detail}</small></article>
|
|
40
|
+
))}
|
|
41
|
+
</section>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function QueueLoadRow({ queue }: { queue: CommandQueueLoad }) {
|
|
46
|
+
const tone = queue.pressure >= 80 ? " danger" : queue.pressure >= 55 ? " warning" : "";
|
|
47
|
+
const width = Math.max(queue.openCount ? 8 : 0, queue.pressure);
|
|
48
|
+
return (
|
|
49
|
+
<article className="queue-load-row">
|
|
50
|
+
<div className="queue-load-label"><strong>{queue.name}</strong><small>{queue.targetMinutes}m first-response target</small></div>
|
|
51
|
+
<span className="queue-load-track" role="progressbar" aria-label={`${queue.name} response pressure`} aria-valuemin={0} aria-valuemax={100} aria-valuenow={queue.pressure}>
|
|
52
|
+
<i className={`queue-load-fill${tone}`} style={{ width: `${width}%` }} />
|
|
53
|
+
</span>
|
|
54
|
+
<strong className="queue-load-count">{queue.openCount} open</strong>
|
|
55
|
+
</article>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function RunRow({ run }: { run: CommandRunSummary }) {
|
|
60
|
+
const tone = run.tone === "neutral" ? "" : ` ${run.tone}`;
|
|
61
|
+
return (
|
|
62
|
+
<article className="compact-run-row">
|
|
63
|
+
<span className="run-glyph" aria-hidden="true">⌘</span>
|
|
64
|
+
<div><strong>{run.title}</strong><small>{run.detail}</small></div>
|
|
65
|
+
<span className={`run-status${tone}`}>{run.status}</span>
|
|
66
|
+
<span className="run-age">{run.age}</span>
|
|
67
|
+
<button className="text-button run-open" type="button" onClick={() => performAction(run.action)}>Open</button>
|
|
68
|
+
</article>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function LoadingRows() {
|
|
73
|
+
return <div className="skeleton-block" aria-hidden="true" />;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function CommandCentre() {
|
|
77
|
+
const snapshot = useSyncExternalStore(subscribeToCommand, getCommandSnapshot, getCommandSnapshot);
|
|
78
|
+
const welcome = greeting(snapshot.generatedAt, snapshot.firstName);
|
|
79
|
+
const attentionSummary = snapshot.metrics.needsHuman
|
|
80
|
+
? `${snapshot.metrics.needsHuman} item${snapshot.metrics.needsHuman === 1 ? "" : "s"} need attention.`
|
|
81
|
+
: "No reports or workflow issues need attention.";
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div data-react-slice="command-centre">
|
|
85
|
+
<PageHeader
|
|
86
|
+
className="command-head"
|
|
87
|
+
title="Command centre"
|
|
88
|
+
description={`${formattedDate(snapshot.generatedAt)} · ${welcome} ${snapshot.loading ? "Loading workspace status." : attentionSummary}`}
|
|
89
|
+
>
|
|
90
|
+
<div className="surface-buttons">
|
|
91
|
+
{snapshot.permissions.useAssistant ? <button className="secondary command-assistant-action" type="button" onClick={() => dispatchNavigation("assistant")}><svg aria-hidden="true"><use href="#i-spark" /></svg>Ask Safest</button> : null}
|
|
92
|
+
{snapshot.permissions.manageWorkflows ? <button className="primary" type="button" onClick={() => dispatchNavigation("workflows")}><svg aria-hidden="true"><use href="#i-plus" /></svg>New workflow</button> : null}
|
|
93
|
+
</div>
|
|
94
|
+
</PageHeader>
|
|
95
|
+
|
|
96
|
+
{snapshot.permissions.manageInstallation && snapshot.incompleteSetupCount > 0 ? (
|
|
97
|
+
<div className="attention-banner"><div><strong>{snapshot.incompleteSetupCount} setup item{snapshot.incompleteSetupCount === 1 ? "" : "s"} need attention</strong><span>Finish setup to verify reporting and delivery.</span></div><button className="secondary" type="button" onClick={() => dispatchNavigation("settings")}>Open setup</button></div>
|
|
98
|
+
) : null}
|
|
99
|
+
|
|
100
|
+
<Metrics metrics={snapshot.metrics} loading={snapshot.loading} />
|
|
101
|
+
|
|
102
|
+
<div className="command-grid">
|
|
103
|
+
<section className="surface command-panel"><div className="surface-head"><h2>Needs attention</h2><button className="text-button" type="button" onClick={() => dispatchNavigation("reports")}>View all</button></div><div className="attention-list" aria-busy={snapshot.loading}>
|
|
104
|
+
{snapshot.loading ? <LoadingRows /> : snapshot.attention.length ? snapshot.attention.map((item) => (
|
|
105
|
+
<button className="attention-row" type="button" key={item.id} onClick={() => performAction(item.action)}><span className={`attention-glyph${item.urgent ? " danger" : ""}`} aria-hidden="true">{item.icon}</span><span><strong>{item.title}</strong><small>{item.detail}</small></span><span className="attention-meta">{item.meta}</span></button>
|
|
106
|
+
)) : <EmptyProduct icon="✓" title="You’re all caught up" description="No reports or workflow issues need attention." />}
|
|
107
|
+
</div></section>
|
|
108
|
+
|
|
109
|
+
<section className="surface command-panel queue-load-panel"><div className="surface-head"><h2>Queue load</h2><span className="live-label"><i />Live</span></div><div className="queue-load-list" aria-busy={snapshot.loading}>
|
|
110
|
+
{snapshot.loading ? <LoadingRows /> : snapshot.queueLoads.length ? snapshot.queueLoads.map((queue) => <QueueLoadRow queue={queue} key={queue.id} />) : <EmptyProduct icon="≋" title="Queue load unavailable" description="Your role does not include queue load data." />}
|
|
111
|
+
</div></section>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<section className="surface command-panel recent-runs-panel"><div className="surface-head"><h2>Recent workflow runs</h2>{snapshot.permissions.viewAudit ? <button className="text-button" type="button" onClick={() => dispatchNavigation("operations")}>View runs</button> : null}</div><div className="compact-run-list" aria-busy={snapshot.loading}>
|
|
115
|
+
{snapshot.loading ? <LoadingRows /> : snapshot.recentRuns.length ? snapshot.recentRuns.map((run) => <RunRow run={run} key={run.id} />) : <EmptyProduct icon="⌘" title="No recent runs" description="Runs appear here when a report starts a workflow." />}
|
|
116
|
+
</div></section>
|
|
117
|
+
</div>
|
|
118
|
+
);
|
|
119
|
+
}
|