create-safest-tools 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/package.json +2 -2
- package/src/config.mjs +2 -2
- package/src/update.mjs +10 -1
- package/template/README.md +2 -2
- package/template/console/README.md +2 -2
- package/template/console/analytics/AnalyticsWorkspace.tsx +49 -36
- package/template/console/analytics/controller.ts +10 -2
- package/template/console/analytics/types.ts +7 -0
- package/template/console/appeals/AppealFollowup.tsx +171 -0
- package/template/console/appeals/AppealWorkspace.tsx +33 -4
- package/template/console/appeals/RecordAppealDialog.tsx +42 -0
- package/template/console/appeals/appeal-corrections.css +29 -0
- package/template/console/appeals/controller.ts +40 -14
- package/template/console/appeals/events.ts +1 -0
- package/template/console/appeals/types.ts +6 -0
- package/template/console/appeals/useAppealDraftGuard.ts +12 -0
- package/template/console/assistant/AssistantWorkspace.tsx +28 -5
- package/template/console/command/CommandCentre.tsx +101 -64
- package/template/console/command/controller.ts +80 -70
- package/template/console/command/events.ts +1 -2
- package/template/console/command/presentation.ts +29 -0
- package/template/console/command/store.ts +4 -4
- package/template/console/command/types.ts +26 -21
- package/template/console/configuration/ConfigurationBuilders.tsx +101 -0
- package/template/console/configuration/ConfigurationDialog.tsx +28 -13
- package/template/console/configuration/ConfigurationWorkspace.tsx +21 -8
- package/template/console/configuration/types.ts +1 -1
- package/template/console/lib/priority.ts +13 -0
- package/template/console/operations/CaseEscalations.tsx +142 -0
- package/template/console/operations/OperationsWorkspace.tsx +2 -0
- package/template/console/operations/case-escalations.css +20 -0
- package/template/console/people/PeopleWorkspace.tsx +8 -4
- package/template/console/people/PersonAccessDialog.tsx +40 -0
- package/template/console/people/types.ts +1 -0
- package/template/console/profile/ProfileWorkspace.tsx +1 -1
- package/template/console/registry/IntegrationSetup.tsx +41 -0
- package/template/console/registry/IntegrationVerification.tsx +54 -0
- package/template/console/registry/RegistryDetailsDialog.tsx +2 -2
- package/template/console/registry/RegistryDialog.tsx +56 -27
- package/template/console/registry/RegistryWorkspace.tsx +10 -5
- package/template/console/registry/integration-verification.css +25 -0
- package/template/console/reports/CaseActions.tsx +81 -0
- package/template/console/reports/CaseContinuity.tsx +27 -0
- package/template/console/reports/CaseNotices.tsx +48 -0
- package/template/console/reports/CaseOwnership.tsx +78 -0
- package/template/console/reports/CaseReviewTasks.tsx +148 -0
- package/template/console/reports/EnrichmentFindings.tsx +148 -0
- package/template/console/reports/InvestigationBrief.tsx +29 -0
- package/template/console/reports/ReportDrawer.tsx +122 -67
- package/template/console/reports/ReportWorkspace.tsx +112 -23
- package/template/console/reports/controller.ts +125 -60
- package/template/console/reports/events.ts +9 -2
- package/template/console/reports/load-inbox.ts +17 -0
- package/template/console/reports/presence-presentation.ts +52 -0
- package/template/console/reports/private-drafts.ts +32 -0
- package/template/console/reports/store.ts +2 -2
- package/template/console/reports/types.ts +50 -2
- package/template/console/reports/use-editing-access.ts +13 -0
- package/template/console/settings/SettingsWorkspace.tsx +1 -1
- package/template/console/shell/WorkspaceShell.tsx +55 -29
- package/template/console/shell/WorkspaceUpdateNotice.tsx +48 -0
- package/template/console/shell/controller.ts +23 -16
- package/template/console/shell/navigation.ts +30 -39
- package/template/console/workflows/CreatePlaybookDialog.tsx +73 -0
- package/template/console/workflows/PlaybookExamples.tsx +160 -0
- package/template/console/workflows/PlaybookFields.tsx +109 -0
- package/template/console/workflows/PlaybookGuideEditor.tsx +125 -0
- package/template/console/workflows/PlaybookHistory.tsx +106 -0
- package/template/console/workflows/WorkflowDialogs.tsx +2 -1
- package/template/console/workflows/WorkflowStudio.tsx +108 -39
- package/template/console/workflows/WorkflowWorkspace.tsx +9 -7
- package/template/console/workflows/templates.ts +3 -3
- package/template/console/workflows/types.ts +11 -0
- package/template/migrations/0001_safest_resolve_baseline.sql +0 -1
- package/template/migrations/0002_playbook_publication_evidence.sql +36 -0
- package/template/migrations/0003_action_confirmation.sql +24 -0
- package/template/migrations/0004_notice_recovery.sql +22 -0
- package/template/migrations/0005_appeal_continuity.sql +17 -0
- package/template/migrations/0006_case_ownership.sql +23 -0
- package/template/migrations/0007_integration_verification.sql +21 -0
- package/template/migrations/0008_case_escalations.sql +135 -0
- package/template/migrations/0009_participant_continuation.sql +61 -0
- package/template/migrations/0010_appeal_corrections.sql +41 -0
- package/template/npm-shrinkwrap.json +8036 -0
- package/template/package.json +31 -22
- package/template/public/_headers +16 -1
- package/template/public/console/auth-shell.js +22151 -14847
- package/template/public/console/release.json +1 -0
- package/template/public/console/workspace-features.css +2 -0
- package/template/public/embed/embed.css +3 -0
- package/template/public/embed/embed.js +19 -1
- package/template/public/embed/index.html +2 -0
- package/template/public/index.html +9 -6
- package/template/public/participant.css +2 -0
- package/template/public/participant.html +13 -0
- package/template/public/participant.js +111 -0
- package/template/public/public-report.js +21 -1
- package/template/public/report/index.html +1 -1
- package/template/public/styles.css +599 -83
- package/template/public/workspace.css +124 -0
- package/template/scripts/reports-plan.mjs +1 -1
- package/template/shared/case-escalations.ts +31 -0
- package/template/shared/integration-progress.ts +27 -0
- package/template/shared/notice-inputs.ts +3 -0
- package/template/shared/playbook-resources.ts +14 -0
- package/template/shared/playbooks.ts +249 -0
- package/template/src/agent-executor.ts +25 -8
- package/template/src/ai-observability.ts +14 -13
- package/template/src/ai-registry-validation.ts +1 -1
- package/template/src/appeal-continuity.ts +75 -0
- package/template/src/appeal-correction-state.ts +46 -0
- package/template/src/appeal-corrections.ts +112 -0
- package/template/src/appeal-decisions.ts +85 -0
- package/template/src/assistant.ts +3 -4
- package/template/src/case-escalations.ts +223 -0
- package/template/src/case-investigator.ts +179 -0
- package/template/src/component-executor.ts +5 -4
- package/template/src/configuration-registry.ts +4 -1
- package/template/src/connection-oauth.ts +12 -3
- package/template/src/database-readiness.ts +14 -0
- package/template/src/human-tasks.ts +152 -55
- package/template/src/index.ts +191 -29
- package/template/src/installation-admin.ts +2 -1
- package/template/src/integration-progress.ts +45 -0
- package/template/src/integration-verification.ts +249 -0
- package/template/src/investigation-evidence.ts +95 -0
- package/template/src/investigator-fixture.ts +30 -0
- package/template/src/notice-delivery.ts +19 -0
- package/template/src/notice-recovery.ts +81 -0
- package/template/src/operator-scope.ts +31 -0
- package/template/src/participant-access.ts +163 -0
- package/template/src/participant-case.ts +87 -0
- package/template/src/participant-customer-access.ts +93 -0
- package/template/src/participant-recovery.ts +43 -0
- package/template/src/participant-routes.ts +67 -0
- package/template/src/platform-registry-validation.ts +25 -2
- package/template/src/platform-registry.ts +10 -7
- package/template/src/playbook-resources.ts +58 -0
- package/template/src/report-admin.ts +9 -2
- package/template/src/report-auth.ts +1 -28
- package/template/src/report-better-auth.ts +18 -6
- package/template/src/report-delivery.ts +149 -148
- package/template/src/report-email-inbound.ts +33 -22
- package/template/src/report-email-template.ts +5 -2
- package/template/src/report-email.ts +7 -30
- package/template/src/report-governance.ts +42 -45
- package/template/src/report-http.ts +37 -2
- package/template/src/report-operations.ts +30 -67
- package/template/src/report-operator-accounts.ts +7 -6
- package/template/src/report-ownership.ts +64 -0
- package/template/src/report-presence.ts +15 -9
- package/template/src/report-repository.ts +16 -7
- package/template/src/report-turnstile.ts +7 -3
- package/template/src/report-types.ts +1 -0
- package/template/src/report-workflow.ts +183 -249
- package/template/src/workflow-actions.ts +248 -156
- package/template/src/workflow-compiler.ts +63 -12
- package/template/src/workflow-dynamic-runtime.ts +20 -9
- package/template/src/workflow-effects.ts +117 -39
- package/template/src/workflow-event-transport.ts +16 -0
- package/template/src/workflow-expressions.ts +30 -0
- package/template/src/workflow-loader-code.ts +21 -0
- package/template/src/workflow-platform-types.ts +3 -0
- package/template/src/workflow-platform-validation.ts +23 -1
- package/template/src/workflow-repository.ts +131 -28
- package/template/src/workflow-runs.ts +61 -13
- package/template/src/workflow-simulator.ts +7 -4
- package/template/src/workspace-governance.ts +6 -4
- package/template/tsconfig.json +1 -0
- package/template/vite.console.config.ts +30 -2
- package/template/worker-configuration.d.ts +3 -2
|
@@ -4,6 +4,7 @@ import { PageHeader } from "../components/PageHeader";
|
|
|
4
4
|
import { errorMessage, postJson, requestJson } from "../lib/http";
|
|
5
5
|
import { relativeTime } from "../lib/format";
|
|
6
6
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
7
|
+
import { PersonAccessDialog } from "./PersonAccessDialog";
|
|
7
8
|
import type { InvitationResponse, PasswordResetResponse, PeopleResponse, WorkspaceInvitation, WorkspacePerson } from "./types";
|
|
8
9
|
|
|
9
10
|
const roleNames: Record<string, string> = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", administrator: "Administrator", owner: "Infrastructure owner", auditor: "Auditor" };
|
|
@@ -24,6 +25,8 @@ export function PeopleWorkspace() {
|
|
|
24
25
|
const [error, setError] = useState("");
|
|
25
26
|
const [inviteUrl, setInviteUrl] = useState("");
|
|
26
27
|
const [resetMessage, setResetMessage] = useState("");
|
|
28
|
+
const [editing, setEditing] = useState<WorkspacePerson | null>(null);
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
27
30
|
const canManage = shell.session?.permissions.managePeople === true;
|
|
28
31
|
const isOwner = shell.session?.roles.includes("owner") === true;
|
|
29
32
|
|
|
@@ -38,10 +41,10 @@ export function PeopleWorkspace() {
|
|
|
38
41
|
finally { setLoading(false); }
|
|
39
42
|
};
|
|
40
43
|
|
|
41
|
-
useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); void load(); }, [shell.session?.actor.id, canManage]);
|
|
44
|
+
useEffect(() => { setLoading(true); setInviteUrl(""); setResetMessage(""); setEditing(null); void load(); }, [shell.session?.actor.id, canManage]);
|
|
42
45
|
|
|
43
46
|
const invite = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
44
|
-
event.preventDefault(); setPending("invite"); setError(""); setInviteUrl("");
|
|
47
|
+
event.preventDefault(); setPending("invite"); setError(""); setInviteUrl(""); setCopied(false);
|
|
45
48
|
try {
|
|
46
49
|
const result = await postJson<InvitationResponse>("/v1/admin/people", { email, role });
|
|
47
50
|
setInviteUrl(result.invitation.inviteUrl); setEmail(""); await load();
|
|
@@ -71,11 +74,12 @@ export function PeopleWorkspace() {
|
|
|
71
74
|
</PageHeader>
|
|
72
75
|
<section className="surface people-surface" aria-labelledby="people-title">
|
|
73
76
|
<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}
|
|
77
|
+
{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).then(() => setCopied(true)).catch(() => setError("Copy failed. Select the invitation link and copy it manually.")); }}>{copied ? "Copied" : "Copy link"}</button></div> : null}
|
|
75
78
|
{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>
|
|
79
|
+
<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(", ")}{person.status === "suspended" ? " · Suspended" : ""}</span><small>{person.lastLoginAt ? `Last active ${relativeTime(person.lastLoginAt)}` : "Not signed in yet"}</small>{canReset ? <div className="people-actions"><button className="secondary compact-button" type="button" onClick={() => setEditing(person)}>Edit access</button><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></div> : 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
80
|
<p className="error surface-error" role="alert">{error}</p>
|
|
78
81
|
</section>
|
|
82
|
+
<PersonAccessDialog person={editing} owner={isOwner} onClose={() => setEditing(null)} onSaved={() => { setResetMessage("Workspace access updated."); void load(); }} />
|
|
79
83
|
</div>
|
|
80
84
|
);
|
|
81
85
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
3
|
+
import type { WorkspacePerson } from "./types";
|
|
4
|
+
|
|
5
|
+
const roles = { reviewer: "Analyst", appeal_reviewer: "Appeal analyst", auditor: "Auditor", administrator: "Administrator", owner: "Infrastructure owner" };
|
|
6
|
+
|
|
7
|
+
export function PersonAccessDialog({ person, owner, onClose, onSaved }: { person: WorkspacePerson | null; owner: boolean; onClose: () => void; onSaved: () => void }) {
|
|
8
|
+
const ref = useRef<HTMLDialogElement>(null);
|
|
9
|
+
const [selected, setSelected] = useState<string[]>([]);
|
|
10
|
+
const [status, setStatus] = useState("active");
|
|
11
|
+
const [pending, setPending] = useState(false);
|
|
12
|
+
const [error, setError] = useState("");
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
setSelected(person?.roles || []); setStatus(person?.status || "active"); setError("");
|
|
15
|
+
if (person && !ref.current?.open) ref.current?.showModal();
|
|
16
|
+
if (!person && ref.current?.open) ref.current.close();
|
|
17
|
+
}, [person]);
|
|
18
|
+
const close = () => { if (!pending) onClose(); };
|
|
19
|
+
const save = async (event: FormEvent<HTMLFormElement>) => {
|
|
20
|
+
event.preventDefault();
|
|
21
|
+
if (!person || pending) return;
|
|
22
|
+
if (!selected.length) { setError("Choose at least one role."); return; }
|
|
23
|
+
if (status === "suspended" && person.status !== "suspended" && !window.confirm(`Suspend ${person.displayName}? Their active sessions will be signed out.`)) return;
|
|
24
|
+
setPending(true); setError("");
|
|
25
|
+
try {
|
|
26
|
+
await requestJson(`/v1/admin/people/${encodeURIComponent(person.id)}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ roles: selected, status }) });
|
|
27
|
+
onSaved(); onClose();
|
|
28
|
+
} catch (cause) { setError(errorMessage(cause, "Access could not be updated.")); }
|
|
29
|
+
finally { setPending(false); }
|
|
30
|
+
};
|
|
31
|
+
return <dialog ref={ref} className="editor-dialog" aria-labelledby="person-access-title" onCancel={(event) => { event.preventDefault(); close(); }}>
|
|
32
|
+
<div className="detail-head"><div><h2 id="person-access-title">Edit access</h2><p>{person?.displayName} · {person?.email}</p></div><button type="button" className="icon-button" aria-label="Close access editor" disabled={pending} onClick={close}>×</button></div>
|
|
33
|
+
<form className="editor-form" onSubmit={(event) => void save(event)}>
|
|
34
|
+
<fieldset disabled={pending}><legend>Workspace roles</legend>{Object.entries(roles).filter(([role]) => owner || !["owner", "administrator"].includes(role)).map(([role, label]) => <label className="checkbox" key={role}><input type="checkbox" checked={selected.includes(role)} onChange={(event) => { const checked = event.currentTarget.checked; setSelected((current) => checked ? [...current, role] : current.filter((value) => value !== role)); }} />{label}</label>)}</fieldset>
|
|
35
|
+
<label>Account status<select disabled={pending} value={status} onChange={(event) => setStatus(event.currentTarget.value)}><option value="active">Active</option><option value="suspended">Suspended</option></select></label>
|
|
36
|
+
<p className="form-help">Suspending access signs out active sessions. Reactivating access lets this teammate sign in again.</p>
|
|
37
|
+
<p role="alert" className="error">{error}</p><div className="editor-footer"><button type="button" className="secondary" disabled={pending} onClick={close}>Cancel</button><button type="submit" className="primary" disabled={pending}>{pending ? "Saving…" : "Save access"}</button></div>
|
|
38
|
+
</form>
|
|
39
|
+
</dialog>;
|
|
40
|
+
}
|
|
@@ -116,7 +116,7 @@ export function ProfileWorkspace() {
|
|
|
116
116
|
<section className="surface profile-surface" aria-labelledby="profile-title">
|
|
117
117
|
{profile ? <div className="profile-layout">
|
|
118
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>
|
|
119
|
+
<div className="profile-avatar-preview" role="img" aria-label="Current profile picture"><AnalystAvatar size="large" analyst={{ displayName: profile.displayName, avatarUrl: profile.avatarUrl ?? null, avatarColor: profile.profile?.avatarColor }} /></div>
|
|
120
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
121
|
{profile.avatarUrl ? <button className="text-button" type="button" disabled={Boolean(pending)} onClick={() => void remove()}>{pending === "remove" ? "Removing…" : "Remove picture"}</button> : null}
|
|
122
122
|
<small>JPEG, PNG, or WebP. Maximum 2 MB.</small>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { dateTime } from "../lib/format";
|
|
3
|
+
import { errorMessage, requestJson } from "../lib/http";
|
|
4
|
+
import { dispatchOpenReport } from "../reports/events";
|
|
5
|
+
import { openChannelDialog, settingsEvents } from "../settings/events";
|
|
6
|
+
import { dispatchNavigation } from "../shell/events";
|
|
7
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
8
|
+
import { registryEvents } from "./events";
|
|
9
|
+
import { IntegrationVerification } from "./IntegrationVerification";
|
|
10
|
+
import type { IntegrationProgress } from "../../shared/integration-progress";
|
|
11
|
+
|
|
12
|
+
export function IntegrationSetup({onTools}:{onTools:(kind:"enrichment"|"action")=>void}) {
|
|
13
|
+
const shell=useSyncExternalStore(subscribeToShell,getShellSnapshot,getShellSnapshot);
|
|
14
|
+
const permissions=shell.session?.permissions??{};
|
|
15
|
+
const [progress,setProgress]=useState<IntegrationProgress|null>(null),[error,setError]=useState("");
|
|
16
|
+
const [loading,setLoading]=useState(false);const version=useRef(0);
|
|
17
|
+
const access=JSON.stringify({actor:shell.session?.actor.id,permissions,scope:shell.session?.queueScope});
|
|
18
|
+
const load=useCallback(async()=>{const request=++version.current;setLoading(true);setError("");try{const result=await requestJson<IntegrationProgress>("/v1/admin/integration-progress");if(request===version.current)setProgress(result);}catch(cause){if(request===version.current)setError(errorMessage(cause,"Setup evidence could not refresh."));}finally{if(request===version.current)setLoading(false);}},[]);
|
|
19
|
+
useEffect(()=>{version.current++;setProgress(null);if(permissions.manageComponents)void load();return()=>{version.current++;};},[access,load]);
|
|
20
|
+
useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);};},[load]);
|
|
21
|
+
const [expanded,setExpanded]=useState<string|null>("intake");
|
|
22
|
+
const config=progress?.configuration;
|
|
23
|
+
const configured=(count:number|null|undefined,label:string)=>count==null?"Configuration access required":`${count} ${label}`;
|
|
24
|
+
const stages=[
|
|
25
|
+
{key:"intake" as const,title:"Receive a report",description:"Start with a hosted form or product widget. Signed customer context keeps identity and content references tied to your application.",configuration:configured(config?.reportingRoutes,"configured reporting routes"),action:"Set up reporting",allowed:permissions.manageIntegrations,run:openChannelDialog},
|
|
26
|
+
{key:"context" as const,title:"Bring in the right context",description:"Publish read-only customer tools for the evidence your reviewers need. Declare the fields each playbook may request.",configuration:configured(config?.customerReads,"published customer reads"),action:"Choose read tools",allowed:true,run:()=>onTools("enrichment")},
|
|
27
|
+
{key:"action" as const,title:"Confirm the customer action",description:"Choose exact targets and approval rules. The case waits for your application to confirm the approved change.",configuration:configured(config?.customerActions,"published customer actions"),action:"Choose action tools",allowed:true,run:()=>onTools("action")},
|
|
28
|
+
{key:"notice" as const,title:"Reach each participant",description:"Write separate public outcomes for the reporter and affected person. A delivery handoff stays distinct from someone reading the notice.",configuration:config?.noticeChannel==null?"Configuration access required":config.noticeChannel?"Participant channel configured":"Participant channel needed",action:"Review notice setup",allowed:permissions.manageInstallation||permissions.manageIntegrations,run:()=>dispatchNavigation("settings")},
|
|
29
|
+
{key:"playbook" as const,title:"Run a complete case",description:"Publish and activate a tested playbook, then submit a fresh report through your customer channel. Follow it from review to the confirmed outcome.",configuration:configured(config?.activePlaybooks,"active playbook queue assignments"),action:"Open playbooks",allowed:permissions.manageWorkflows,run:()=>dispatchNavigation("workflows")},
|
|
30
|
+
];
|
|
31
|
+
return <>{permissions.manageIntegrations&&permissions.manageWorkflows&&permissions.readReports&&permissions.readEvidence&&progress?.canReadCases===true?<IntegrationVerification key={access}/>:null}<section className="integration-setup surface" aria-label="Customer connection setup">
|
|
32
|
+
<div className="surface-head"><div><h2>Customer connection setup reference</h2><p>Start with reporting. Add customer context, approved actions and participant delivery as your playbook needs them.</p></div><button className="secondary" type="button" disabled={loading} onClick={()=>void load()}>{loading?"Checking records…":"Refresh historical evidence"}</button></div>
|
|
33
|
+
{progress?.localSandbox?<p className="case-guidance">Local sandbox records. Model fixtures and local customer receivers do not verify production model judgement or external delivery.</p>:null}
|
|
34
|
+
<ol className="integration-stages" aria-busy={loading}>{stages.map((stage,index)=>{const evidence=progress?.evidence[stage.key];const open=expanded===stage.key;return <li key={stage.key}>
|
|
35
|
+
<h3><button type="button" className="integration-stage-toggle" aria-expanded={open} aria-controls={`integration-stage-${stage.key}`} onClick={()=>setExpanded(open?null:stage.key)}><span className="integration-stage-number" aria-hidden="true">{index+1}</span><span>{stage.title}<small>{progress?stage.configuration:"Loading configuration…"}</small></span><span aria-hidden="true">{open?"−":"+"}</span></button></h3>
|
|
36
|
+
<div id={`integration-stage-${stage.key}`} hidden={!open} className="integration-stage-detail"><p>{stage.description}</p><div className="integration-stage-evidence">{evidence?<><strong>Historical case evidence</strong><button className="text-button" type="button" onClick={()=>dispatchOpenReport(evidence.reportId)}>{evidence.reference}</button><small>{dateTime(evidence.observedAt)}</small></>:<span>{progress?.canReadCases===false?"Case evidence is restricted for this role":progress?"No matching case evidence in your access yet":"Checking saved evidence…"}</span>}</div>{stage.allowed?<button className="secondary" type="button" onClick={stage.run}>{stage.action}</button>:<small>Ask your workspace administrator to complete this step.</small>}</div>
|
|
37
|
+
</li>;})}</ol>
|
|
38
|
+
<p className="form-help">These are saved configuration and case records, not a live health check. Run a fresh case after changes. Contract examples alone do not complete this journey.</p>
|
|
39
|
+
{error?<p className="error" role="alert">{error} {progress?"Previously loaded records remain visible.":""}</p>:null}
|
|
40
|
+
</section></>;
|
|
41
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
3
|
+
import { workflowEvents } from "../workflows/events";
|
|
4
|
+
import type { IntegrationVerificationList, IntegrationVerification as Check } from "../../shared/integration-progress";
|
|
5
|
+
import { dateTime } from "../lib/format";
|
|
6
|
+
import { errorMessage, postJson, requestJson } from "../lib/http";
|
|
7
|
+
import { dispatchOpenReport } from "../reports/events";
|
|
8
|
+
import { settingsEvents } from "../settings/events";
|
|
9
|
+
import { registryEvents } from "./events";
|
|
10
|
+
import "./integration-verification.css";
|
|
11
|
+
|
|
12
|
+
const stateLabel: Record<Check['state'],string> = { awaiting_case:"Waiting for a fresh report",in_progress:"Check in progress",blocked:"Needs attention",stale:"Configuration changed",verified:"Selected outcome verified" };
|
|
13
|
+
export function IntegrationVerification() {
|
|
14
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
15
|
+
const [data,setData] = useState<IntegrationVerificationList|null>(null), [error,setError] = useState(''), [busy,setBusy] = useState(false);
|
|
16
|
+
const [integration,setIntegration] = useState(''), [form,setForm] = useState(''), [queue,setQueue] = useState(''), [decision,setDecision] = useState('');
|
|
17
|
+
const [activeId,setActiveId] = useState(''), [candidate,setCandidate] = useState('');
|
|
18
|
+
const generation = useRef(0);
|
|
19
|
+
const load = useCallback(async()=>{const version=++generation.current;setBusy(true);setError('');try {const result=await requestJson<IntegrationVerificationList>('/v1/admin/integration-verifications');if(version===generation.current)setData(result);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'Verification could not refresh.'));}finally{if(version===generation.current)setBusy(false);}},[]);
|
|
20
|
+
useEffect(()=>{if(shell.currentView==='components')void load();return()=>{generation.current++;};},[load,shell.currentView]);
|
|
21
|
+
useEffect(()=>{const refresh=()=>void load();window.addEventListener(registryEvents.changed,refresh);window.addEventListener(settingsEvents.channelsChanged,refresh);window.addEventListener(workflowEvents.changed,refresh);return()=>{window.removeEventListener(registryEvents.changed,refresh);window.removeEventListener(settingsEvents.channelsChanged,refresh);window.removeEventListener(workflowEvents.changed,refresh);};},[load]);
|
|
22
|
+
const choices=data?.choices;
|
|
23
|
+
const selectedIntegration=choices?.integrations.find(item=>item.id===integration);
|
|
24
|
+
const selectedPlaybook=choices?.playbooks.find(item=>item.queueId===queue);
|
|
25
|
+
const selectedForm=selectedIntegration?.formVersionId||form;
|
|
26
|
+
const active=data?.runs.find(item=>item.id===activeId)||data?.runs[0];
|
|
27
|
+
useEffect(()=>{setCandidate('');},[active?.id]);
|
|
28
|
+
useEffect(()=>{if(choices?.integrations.length===1&&!integration)setIntegration(choices.integrations[0]!.id);if(choices?.playbooks.length===1&&!queue)setQueue(choices.playbooks[0]!.queueId);},[choices,integration,queue]);
|
|
29
|
+
const save = async(path:string,body:unknown)=>{const version=++generation.current;setBusy(true);setError('');try {const result=await postJson<Check>(path,body);if(version!==generation.current)return;setActiveId(result.id);setData(previous=>previous?{...previous,runs:[result,...previous.runs.filter(item=>item.id!==result.id)].slice(0,5)}:previous);}catch(cause){if(version===generation.current)setError(errorMessage(cause,'The check could not be saved.'));}finally{if(version===generation.current)setBusy(false);}};
|
|
30
|
+
return <section className="integration-verification surface" aria-label="Verify a customer connection">
|
|
31
|
+
<div className="surface-head"><div><h2>Verify one complete customer journey</h2><p>Follow one fresh case from your reporting channel to a confirmed outcome using the current configuration.</p></div><button type="button" className="secondary" disabled={busy} onClick={()=>void load()}>{busy?'Checking…':'Refresh verification'}</button></div>
|
|
32
|
+
<form className="verification-start" onSubmit={event=>{event.preventDefault();void save('/v1/admin/integration-verifications',{integration_id:integration,form_version_id:selectedForm,queue_id:queue,decision_code:decision});}}>
|
|
33
|
+
<label>Reporting integration<select value={integration} onChange={event=>{setIntegration(event.target.value);setForm('');}} required disabled={busy}><option value="">Choose an integration</option>{choices?.integrations.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
|
34
|
+
<label>Reporting form<select value={selectedForm} onChange={event=>setForm(event.target.value)} required disabled={busy||Boolean(selectedIntegration?.formVersionId)}><option value="">Choose a form</option>{choices?.forms.filter(item=>!selectedIntegration?.formVersionId||item.id===selectedIntegration.formVersionId).map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
|
|
35
|
+
<label>Queue and active playbook<select value={queue} onChange={event=>{setQueue(event.target.value);setDecision('');}} required disabled={busy}><option value="">Choose a queue</option>{choices?.playbooks.map(item=><option key={item.queueId} value={item.queueId}>{item.queueName} · {item.name}</option>)}</select></label>
|
|
36
|
+
<label>Outcome to verify<select value={decision} onChange={event=>setDecision(event.target.value)} required disabled={busy||!selectedPlaybook}><option value="">Choose an outcome</option>{selectedPlaybook?.decisionCodes.map(code=><option key={code} value={code}>{code.replaceAll('_',' ')}</option>)}</select></label>
|
|
37
|
+
<p className="form-help">Use a safe test account and target. Starting a check changes no customer data. The case you submit afterward follows normal action approvals and participant delivery.</p>
|
|
38
|
+
<button className="primary" type="submit" disabled={busy||!integration||!selectedForm||!queue||!decision}>Start a new verification</button>
|
|
39
|
+
</form>
|
|
40
|
+
{data&&!choices?.integrations.length?<p className="case-guidance">Set up a reporting integration before starting a check.</p>:null}
|
|
41
|
+
{data&&!choices?.playbooks.length?<p className="case-guidance">Publish and activate a primary playbook for an accessible queue first.</p>:null}
|
|
42
|
+
{data&&data.runs.length>1?<label className="verification-history">Recent checks<select value={active?.id||''} onChange={event=>setActiveId(event.target.value)}>{data.runs.map(run=><option key={run.id} value={run.id}>{run.integrationName} · {run.decisionCode.replaceAll('_',' ')} · {dateTime(run.startedAt)}</option>)}</select></label>:null}
|
|
43
|
+
{active?<article className="verification-run" aria-label="Current verification" aria-busy={busy}>
|
|
44
|
+
<div className="verification-summary"><h3>{active.integrationName} · {active.decisionCode.replaceAll('_',' ')}</h3><strong className={`verification-state verification-state-${error?'blocked':active.state}`} role="status">{error?'Verification unavailable':stateLabel[active.state]}</strong><p>{active.formName} → {active.queueName} → {active.playbookName}</p><small>Started {dateTime(active.startedAt)}</small></div>
|
|
45
|
+
{active.localSandbox?<p className="case-guidance">Local sandbox check. These records do not verify production providers or delivery.</p>:null}
|
|
46
|
+
{active.state==='stale'?<p className="case-guidance">The configuration changed since this check started. Start a new verification, then submit another fresh report. These results cannot verify the current setup.</p>:null}
|
|
47
|
+
{active.report?<button className="secondary" type="button" onClick={()=>dispatchOpenReport(active.report!.id)}>Open case {active.report.reference}</button>:null}
|
|
48
|
+
{active.state==='awaiting_case'?<div className="verification-bind"><p>Submit a report through <strong>{active.integrationName}</strong> using <strong>{active.formName}</strong>, with an example that should reach <strong>{active.queueName}</strong> and the selected outcome. Refresh after the report reaches its playbook.</p>{active.candidates.length?<><label>Fresh report to follow<select value={candidate} onChange={event=>setCandidate(event.target.value)} disabled={busy}><option value="">Choose the report you submitted</option>{active.candidates.map(item=><option key={item.id} value={item.id}>{item.reference} · {dateTime(item.receivedAt)}</option>)}</select></label><button type="button" disabled={busy||!candidate} onClick={()=>void save(`/v1/admin/integration-verifications/${active.id}/bind`,{report_id:candidate})}>Follow this case</button></>:<p className="form-help">No matching fresh case has reached this queue yet. If a report is missing, check its routing in Cases. Earlier reports cannot verify this check.</p>}</div>:null}
|
|
49
|
+
<ol className="verification-evidence">{active.stages.map(item=><li key={item.key} data-state={item.state}><div><strong>{item.title}</strong><span>{item.state==='complete'?'Confirmed':item.state==='not_required'?'Not required':item.state==='blocked'?'Needs attention':'Waiting'}</span></div><p>{item.detail}</p></li>)}</ol>
|
|
50
|
+
<p className="form-help">This check covers this integration, form, queue, playbook and selected outcome only. Verify other customer action paths separately. Status is rechecked against current configuration whenever you refresh.</p>
|
|
51
|
+
</article>:null}
|
|
52
|
+
{error?<p role="alert" className="error">{error} {data?'Refresh before relying on previously displayed results.':''}</p>:null}
|
|
53
|
+
</section>;
|
|
54
|
+
}
|
|
@@ -124,7 +124,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
|
|
|
124
124
|
useEffect(() => {
|
|
125
125
|
if (!item) return;
|
|
126
126
|
let active = true;
|
|
127
|
-
setLoading(true); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
|
|
127
|
+
setLoading(true); setPending(""); setError(""); setDetail(null); setDependencies(null); setValidation(null); setConfirmArchive(false);
|
|
128
128
|
void Promise.all([
|
|
129
129
|
requestJson<RegistryDetailResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}`),
|
|
130
130
|
requestJson<RegistryDependenciesResponse>(`/v1/admin/${route}/${encodeURIComponent(item.id)}/dependencies`),
|
|
@@ -239,7 +239,7 @@ export function RegistryDetailsDialog({ item, kind, canManage, onClose, onChange
|
|
|
239
239
|
<button className="danger-link" type="button" disabled={Boolean(pending) || dependencyList.length > 0} onClick={() => setConfirmArchive(true)}>Archive {kind === "component" ? "tool" : "connection"}</button>
|
|
240
240
|
</div>
|
|
241
241
|
{dependencyList.length ? <p className="registry-blocked-copy">Archive is unavailable until the dependencies above are retired or moved to another version.</p> : null}
|
|
242
|
-
{confirmArchive ? <div className="registry-archive-confirm" role="alert"><div><strong>Archive {item.name}?</strong><p>It will disappear from normal lists and cannot be restored. Version and audit records will remain.</p></div><div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => setConfirmArchive(false)}>Cancel</button><button className="danger" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
|
|
242
|
+
{confirmArchive ? <div className="registry-archive-confirm" role="alert"><div><strong>Archive {item.name}?</strong><p>It will disappear from normal lists and cannot be restored. Version and audit records will remain.</p></div><div><button className="secondary" type="button" disabled={Boolean(pending)} onClick={() => setConfirmArchive(false)}>Cancel</button><button className="danger-action" type="button" disabled={Boolean(pending)} onClick={() => void setLifecycle("archive")}>{pending === "archive" ? "Archiving…" : "Archive permanently"}</button></div></div> : null}
|
|
243
243
|
</section> : null}
|
|
244
244
|
</>}
|
|
245
245
|
<p className="error" role="alert">{error}</p>
|
|
@@ -1,23 +1,24 @@
|
|
|
1
|
-
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent } from "react";
|
|
1
|
+
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent } from "react";
|
|
2
2
|
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
3
3
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
4
4
|
import { announceRegistryChanged, registryEvents } from "./events";
|
|
5
5
|
import type { ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
|
|
6
6
|
|
|
7
|
-
type ToolPurpose = "enrichment" | "action" | "ai"
|
|
8
|
-
type ComponentKind = "
|
|
9
|
-
type ImplementationKind = "
|
|
7
|
+
type ToolPurpose = "enrichment" | "action" | "ai";
|
|
8
|
+
type ComponentKind = "webhook_enrichment" | "api_enrichment" | "workers_ai" | "action";
|
|
9
|
+
type ImplementationKind = "webhook" | "external_api" | "workers_ai";
|
|
10
10
|
type ConnectionKind = "webhook" | "external_api" | "customer_worker" | "ai_provider" | "notification" | "email";
|
|
11
11
|
type CredentialStrategy = "none" | "static_header" | "bearer" | "hmac" | "service_binding";
|
|
12
12
|
|
|
13
13
|
const inputSchemaDefault = JSON.stringify({ type: "object", properties: { report: { type: "object" } } }, null, 2);
|
|
14
14
|
const outputSchemaDefault = JSON.stringify({ type: "object", properties: { result: { type: "string" } } }, null, 2);
|
|
15
|
+
const fixtureInputDefault = JSON.stringify({ report: {} }, null, 2);
|
|
16
|
+
const fixtureOutputDefault = JSON.stringify({ result: "ok" }, null, 2);
|
|
15
17
|
|
|
16
18
|
const purposeOptions: Array<{ value: ToolPurpose; title: string; description: string; meta: string; icon: string }> = [
|
|
17
19
|
{ value: "enrichment", title: "Enrichment", description: "Read customer context and add findings to a report.", meta: "Read-only", icon: "+" },
|
|
18
20
|
{ value: "action", title: "Action", description: "Apply a reviewed change in a customer system.", meta: "Approval required", icon: "⚡" },
|
|
19
21
|
{ value: "ai", title: "AI analysis", description: "Classify, summarize, or analyze report evidence.", meta: "Read-only", icon: "✦" },
|
|
20
|
-
{ value: "message", title: "Message", description: "Send a reusable, governed report communication.", meta: "Read-only", icon: "✉" },
|
|
21
22
|
];
|
|
22
23
|
|
|
23
24
|
function slug(value: string): string {
|
|
@@ -36,19 +37,15 @@ function jsonObject(value: string, label: string): Record<string, unknown> {
|
|
|
36
37
|
|
|
37
38
|
function componentForPurpose(purpose: ToolPurpose, implementation: ImplementationKind): ComponentKind {
|
|
38
39
|
if (purpose === "action") return "action";
|
|
39
|
-
if (purpose === "ai") return
|
|
40
|
-
if (purpose === "message") return "message";
|
|
40
|
+
if (purpose === "ai") return "workers_ai";
|
|
41
41
|
if (implementation === "webhook") return "webhook_enrichment";
|
|
42
|
-
|
|
43
|
-
return "built_in";
|
|
42
|
+
return "api_enrichment";
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
function referenceLabel(implementation: ImplementationKind): string {
|
|
47
46
|
if (["webhook", "external_api"].includes(implementation)) return "Operation";
|
|
48
47
|
if (implementation === "workers_ai") return "Workers AI model";
|
|
49
|
-
|
|
50
|
-
if (implementation === "message_template") return "Message template key";
|
|
51
|
-
return "Built-in handler";
|
|
48
|
+
return "Implementation";
|
|
52
49
|
}
|
|
53
50
|
|
|
54
51
|
export function RegistryDialog() {
|
|
@@ -64,14 +61,16 @@ export function RegistryDialog() {
|
|
|
64
61
|
const [keyEdited, setKeyEdited] = useState(false);
|
|
65
62
|
const [description, setDescription] = useState("");
|
|
66
63
|
const [purpose, setPurpose] = useState<ToolPurpose>("enrichment");
|
|
67
|
-
const [implementation, setImplementation] = useState<ImplementationKind>("
|
|
68
|
-
const [reference, setReference] = useState("
|
|
64
|
+
const [implementation, setImplementation] = useState<ImplementationKind>("external_api");
|
|
65
|
+
const [reference, setReference] = useState("POST /");
|
|
69
66
|
const [connectionVersionId, setConnectionVersionId] = useState("");
|
|
70
67
|
const [risk, setRisk] = useState("low");
|
|
71
68
|
const [inputFields, setInputFields] = useState("report.id, report.reason_code");
|
|
72
69
|
const [classifications, setClassifications] = useState("report_operational");
|
|
73
70
|
const [inputSchema, setInputSchema] = useState(inputSchemaDefault);
|
|
74
71
|
const [outputSchema, setOutputSchema] = useState(outputSchemaDefault);
|
|
72
|
+
const [fixtureInput, setFixtureInput] = useState(fixtureInputDefault);
|
|
73
|
+
const [fixtureOutput, setFixtureOutput] = useState(fixtureOutputDefault);
|
|
75
74
|
const [connections, setConnections] = useState<ConnectionSummary[]>([]);
|
|
76
75
|
const [connectionKind, setConnectionKind] = useState<ConnectionKind>("external_api");
|
|
77
76
|
const [targetMode, setTargetMode] = useState<"url" | "service">("url");
|
|
@@ -81,11 +80,16 @@ export function RegistryDialog() {
|
|
|
81
80
|
const [paths, setPaths] = useState("/");
|
|
82
81
|
const [credential, setCredential] = useState<CredentialStrategy>("none");
|
|
83
82
|
const [secretReference, setSecretReference] = useState("");
|
|
83
|
+
const [correctionEnabled, setCorrectionEnabled] = useState(false);
|
|
84
|
+
const [correctionCode, setCorrectionCode] = useState("");
|
|
85
|
+
const [correctsCodes, setCorrectsCodes] = useState("");
|
|
84
86
|
|
|
85
87
|
const reset = (): void => {
|
|
86
88
|
setStep(0); setName(""); setKey(""); setKeyEdited(false); setDescription(""); setPurpose("enrichment");
|
|
87
|
-
setImplementation("
|
|
89
|
+
setImplementation("external_api"); setReference("POST /"); setConnectionVersionId(""); setRisk("low");
|
|
88
90
|
setInputFields("report.id, report.reason_code"); setClassifications("report_operational"); setInputSchema(inputSchemaDefault); setOutputSchema(outputSchemaDefault);
|
|
91
|
+
setFixtureInput(fixtureInputDefault); setFixtureOutput(fixtureOutputDefault);
|
|
92
|
+
setCorrectionEnabled(false); setCorrectionCode(""); setCorrectsCodes("");
|
|
89
93
|
setConnectionKind("external_api"); setTargetMode("url"); setBaseUrl(""); setServiceBinding(""); setMethods("POST"); setPaths("/"); setCredential("none"); setSecretReference(""); setError("");
|
|
90
94
|
};
|
|
91
95
|
const close = (): void => { if (!pending) { setKind(null); reset(); } };
|
|
@@ -127,19 +131,24 @@ export function RegistryDialog() {
|
|
|
127
131
|
|
|
128
132
|
const selectPurpose = (next: ToolPurpose): void => {
|
|
129
133
|
setPurpose(next);
|
|
130
|
-
if (next
|
|
134
|
+
if (next !== "action") setCorrectionEnabled(false);
|
|
135
|
+
if (next === "enrichment") { setImplementation("external_api"); setReference("POST /"); setRisk("low"); }
|
|
131
136
|
if (next === "action") { setImplementation("external_api"); setReference("POST /"); setRisk("high"); }
|
|
132
137
|
if (next === "ai") { setImplementation("workers_ai"); setReference("@cf/meta/llama-3.1-8b-instruct"); setRisk("medium"); }
|
|
133
|
-
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const useCorrectionContract = (): void => {
|
|
141
|
+
setInputFields("reason, original_operation_id");
|
|
142
|
+
setInputSchema(JSON.stringify({ type: "object", properties: { reason: { type: "string", minLength: 1, maxLength: 2000 }, original_operation_id: { type: "string", minLength: 1 } }, required: ["reason", "original_operation_id"], additionalProperties: false }, null, 2));
|
|
143
|
+
setFixtureInput(JSON.stringify({ reason: "An independent appeal reversed the original decision.", original_operation_id: "example-original-operation" }, null, 2));
|
|
144
|
+
setOutputSchema(JSON.stringify({ type: "object", properties: { action_id: { type: "string" }, operation_id: { type: "string" }, payload_digest: { type: "string" }, status: { type: "string", enum: ["accepted", "applied", "failed"] } }, required: ["action_id", "operation_id", "payload_digest", "status"] }, null, 2));
|
|
145
|
+
setFixtureOutput(JSON.stringify({ action_id: "example-correction", operation_id: "example-correction-operation", payload_digest: `sha256:${"a".repeat(64)}`, status: "applied" }, null, 2));
|
|
134
146
|
};
|
|
135
147
|
|
|
136
148
|
const changeImplementation = (next: ImplementationKind): void => {
|
|
137
149
|
setImplementation(next); setConnectionVersionId("");
|
|
138
|
-
if (next === "built_in") setReference("custom-enrichment-v1");
|
|
139
150
|
if (["webhook", "external_api"].includes(next)) setReference("POST /");
|
|
140
151
|
if (next === "workers_ai") setReference("@cf/meta/llama-3.1-8b-instruct");
|
|
141
|
-
if (next === "specialist_agent") setReference("custom-specialist-v1");
|
|
142
|
-
if (next === "message_template") setReference("report-update-v1");
|
|
143
152
|
};
|
|
144
153
|
|
|
145
154
|
const changeTargetMode = (next: "url" | "service"): void => {
|
|
@@ -160,8 +169,14 @@ export function RegistryDialog() {
|
|
|
160
169
|
if (kind === "component") {
|
|
161
170
|
if (!reference.trim()) { setError(`Enter the ${referenceLabel(implementation).toLowerCase()}.`); return false; }
|
|
162
171
|
if (needsConnection && !connectionVersionId) { setError("Select the connection this tool should use."); return false; }
|
|
172
|
+
if (isAction && correctionEnabled && (!/^[a-z][a-z0-9_:-]{0,99}$/u.test(correctionCode) || !commaValues(correctsCodes).length || !commaValues(correctsCodes).every(code => /^[a-z][a-z0-9_:-]{0,99}$/u.test(code)))) {
|
|
173
|
+
setError("Enter the correction action code and the original action codes it can correct."); return false;
|
|
174
|
+
}
|
|
163
175
|
if (!commaValues(inputFields).length || !commaValues(classifications).length) { setError("Add at least one allowed input field and data classification."); return false; }
|
|
164
|
-
try {
|
|
176
|
+
try {
|
|
177
|
+
jsonObject(inputSchema, "Input schema"); jsonObject(outputSchema, "Output schema");
|
|
178
|
+
jsonObject(fixtureInput, "Example input"); jsonObject(fixtureOutput, "Example output");
|
|
179
|
+
} catch (cause) { setError(errorMessage(cause, "Fix the JSON contracts.")); return false; }
|
|
165
180
|
} else {
|
|
166
181
|
if (!commaValues(methods).length || !commaValues(paths).every((value) => value.startsWith("/"))) { setError("Allow at least one method and use absolute paths beginning with /."); return false; }
|
|
167
182
|
if (needsSecret && !/^(?:CONNECTION|OAUTH|ACTION|NOTIFICATION|EMAIL|AI)_[A-Z0-9_]{2,96}$/u.test(secretReference)) { setError("Enter a valid Worker secret binding name, not the secret value."); return false; }
|
|
@@ -170,7 +185,12 @@ export function RegistryDialog() {
|
|
|
170
185
|
return true;
|
|
171
186
|
};
|
|
172
187
|
|
|
173
|
-
const next = (): void => {
|
|
188
|
+
const next = (event: MouseEvent<HTMLButtonElement>): void => {
|
|
189
|
+
// Prevent the same click from submitting if React changes Continue into
|
|
190
|
+
// the submit button while rendering the final review step.
|
|
191
|
+
event.preventDefault();
|
|
192
|
+
if (validateStep()) setStep((current) => Math.min(2, current + 1));
|
|
193
|
+
};
|
|
174
194
|
const back = (): void => { setError(""); setStep((current) => Math.max(0, current - 1)); };
|
|
175
195
|
|
|
176
196
|
const submit = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
@@ -201,8 +221,8 @@ export function RegistryDialog() {
|
|
|
201
221
|
connection_version_id: needsConnection ? connectionVersionId : null, risk_class: risk,
|
|
202
222
|
timeout_policy: { timeout_ms: 5000 }, retry_policy: { maximum_attempts: isAction ? 5 : 3, backoff: "exponential" },
|
|
203
223
|
cache_policy: { enabled: !isAction }, concurrency_policy: { maximum_concurrency: 8 }, budget_policy: {},
|
|
204
|
-
approval_policy: isAction ? { mode: "human" } : {}, tool_manifest: [],
|
|
205
|
-
compatibility: { fixture_evidence: {
|
|
224
|
+
approval_policy: isAction ? { mode: "human", ...(correctionEnabled ? { correction: { action_code: correctionCode, corrects_action_codes: commaValues(correctsCodes) } } : {}) } : {}, tool_manifest: [],
|
|
225
|
+
compatibility: { fixture_evidence: { fixtures: [{ name: "Contract example", input: jsonObject(fixtureInput, "Example input"), expected_output: jsonObject(fixtureOutput, "Example output"), expected_status: "succeeded", mode: "simulated" }] } },
|
|
206
226
|
},
|
|
207
227
|
}),
|
|
208
228
|
});
|
|
@@ -215,14 +235,23 @@ export function RegistryDialog() {
|
|
|
215
235
|
const componentSetup = <>
|
|
216
236
|
<div className={`safety-boundary ${isAction ? "registry-action-boundary" : ""}`}><strong>{isAction ? "Can change customer data" : "Read-only authority"}</strong><span>{isAction ? "Every workflow using this action must require human or bounded approval." : "This tool may add context and findings but cannot change customer data."}</span></div>
|
|
217
237
|
<div className="form-grid">
|
|
218
|
-
<label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "
|
|
238
|
+
<label>How it runs<select value={implementation} onChange={(event) => changeImplementation(event.currentTarget.value as ImplementationKind)}>{purpose === "ai" ? <option value="workers_ai">Workers AI</option> : <><option value="external_api">Connected API</option><option value="webhook">Connected webhook</option></>}</select><small>{needsConnection ? "Requests use a separately governed connection." : "Runs inside this Resolve deployment."}</small></label>
|
|
219
239
|
<label>{referenceLabel(implementation)}<input value={reference} onChange={(event) => setReference(event.currentTarget.value)} /><small>{needsConnection ? "For example: POST /v1/enrich/report" : "An immutable runtime reference."}</small></label>
|
|
220
240
|
{needsConnection ? <label className="wide-field">Connection<select value={connectionVersionId} onChange={(event) => setConnectionVersionId(event.currentTarget.value)}><option value="">Select a connection</option>{activeConnections.map((connection) => <option value={connection.activeVersionId || ""} key={connection.id}>{connection.name} · v{connection.version || 1}</option>)}</select>{!activeConnections.length ? <small>No active connections are available. Cancel this setup and add a connection first.</small> : <small>The tool pins this exact connection version for reproducibility.</small>}</label> : null}
|
|
221
241
|
<label>Risk level<select value={risk} onChange={(event) => setRisk(event.currentTarget.value)}><option value="low">Low</option><option value="medium">Medium</option><option value="high">High</option><option value="critical">Critical</option></select></label>
|
|
242
|
+
{isAction ? <label className="wide-field"><span><input type="checkbox" checked={correctionEnabled} onChange={event => {
|
|
243
|
+
setCorrectionEnabled(event.currentTarget.checked);
|
|
244
|
+
if (event.currentTarget.checked && inputSchema === inputSchemaDefault && outputSchema === outputSchemaDefault) useCorrectionContract();
|
|
245
|
+
}} /> Use for appeal corrections</span><small>Only an explicitly approved appeal correction can use this capability to correct an earlier customer action.</small></label> : null}
|
|
246
|
+
{isAction && correctionEnabled ? <>
|
|
247
|
+
<label>Correction action code<input value={correctionCode} maxLength={100} placeholder="restore_message" onChange={event => setCorrectionCode(event.currentTarget.value)} /><small>The action code accepted by your customer endpoint.</small></label>
|
|
248
|
+
<label>Original action codes<input value={correctsCodes} maxLength={1000} placeholder="hide_message" onChange={event => setCorrectsCodes(event.currentTarget.value)} /><small>Comma-separated codes this capability is allowed to correct.</small></label>
|
|
249
|
+
<div className="wide-field"><p>The correction pins the original operation automatically. Reviewers provide the reason and any additional typed fields in your contract.</p><button type="button" className="secondary" onClick={useCorrectionContract}>Use standard correction contract</button><small>Replaces the input/output schemas and examples with the reason, original operation and signed receipt contract.</small></div>
|
|
250
|
+
</> : null}
|
|
222
251
|
<label>Data classifications<input value={classifications} onChange={(event) => setClassifications(event.currentTarget.value)} /><small>Comma-separated labels used for governance.</small></label>
|
|
223
252
|
<label className="wide-field">Allowed report fields<input value={inputFields} onChange={(event) => setInputFields(event.currentTarget.value)} /><small>Only these report fields are disclosed when the tool runs.</small></label>
|
|
224
253
|
</div>
|
|
225
|
-
<details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return.</p><div className="form-grid"><label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label><label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label></div></details>
|
|
254
|
+
<details className="registry-advanced"><summary>Advanced data contracts</summary><p>JSON schemas constrain what the workflow sends and what the tool may return. The example must match both schemas and is checked when the tool is created.</p><div className="form-grid"><label className="wide-field">Input schema<textarea className="code-input" spellCheck={false} value={inputSchema} onChange={(event) => setInputSchema(event.currentTarget.value)} /></label><label className="wide-field">Example input<textarea className="code-input" spellCheck={false} value={fixtureInput} onChange={(event) => setFixtureInput(event.currentTarget.value)} /></label><label className="wide-field">Output schema<textarea className="code-input" spellCheck={false} value={outputSchema} onChange={(event) => setOutputSchema(event.currentTarget.value)} /></label><label className="wide-field">Example output<textarea className="code-input" spellCheck={false} value={fixtureOutput} onChange={(event) => setFixtureOutput(event.currentTarget.value)} /></label></div></details>
|
|
226
255
|
</>;
|
|
227
256
|
|
|
228
257
|
const connectionSecurity = <>
|
|
@@ -244,7 +273,7 @@ export function RegistryDialog() {
|
|
|
244
273
|
{kind === "component" && step === 1 ? <><div className="registry-step-heading"><h3>Configure the {purpose}</h3><p>Choose where it runs and disclose only the data it needs.</p></div>{componentSetup}</> : null}
|
|
245
274
|
{kind === "connection" && step === 0 ? <><div className="registry-step-heading"><h3>Where should tools connect?</h3><p>A connection secures one destination. Multiple tools can reuse it.</p></div><div className="form-grid"><label>Name<input ref={nameRef} maxLength={160} value={name} onChange={(event) => { const value = event.currentTarget.value; setName(value); if (!keyEdited) setKey(slug(value)); }} /></label><label>Key<input maxLength={100} value={key} onChange={(event) => { setKeyEdited(true); setKey(event.currentTarget.value); }} /></label><label>Connection type<select value={connectionKind} onChange={(event) => setConnectionKind(event.currentTarget.value as ConnectionKind)}><option value="external_api">External API</option><option value="customer_worker">Customer Worker</option><option value="webhook">Webhook</option><option value="ai_provider">AI provider</option><option value="notification">Notification</option><option value="email">Email</option></select></label><label>Destination type<select value={targetMode} onChange={(event) => changeTargetMode(event.currentTarget.value as "url" | "service")}><option value="url">HTTPS endpoint</option><option value="service">Worker service binding</option></select></label>{targetMode === "url" ? <label className="wide-field">HTTPS origin<input type="url" placeholder="https://api.example.com" value={baseUrl} onChange={(event) => setBaseUrl(event.currentTarget.value)} /><small>Origin only; paths are allowlisted in the next step.</small></label> : <label className="wide-field">Service binding<input placeholder="CUSTOMER_API" value={serviceBinding} onChange={(event) => setServiceBinding(event.currentTarget.value)} /></label>}<label className="wide-field">Description<textarea maxLength={2000} value={description} onChange={(event) => setDescription(event.currentTarget.value)} /></label></div></> : null}
|
|
246
275
|
{kind === "connection" && step === 1 ? <><div className="registry-step-heading"><h3>Limit access and configure authentication</h3><p>Use the least authority this connection needs.</p></div>{connectionSecurity}</> : null}
|
|
247
|
-
{step === 2 ? <><div className="registry-step-heading"><h3>Review and create</h3><p>Confirm the security boundary before publishing version 1.</p></div><section className="registry-review"><dl><div><dt>Name</dt><dd>{name}</dd></div><div><dt>Type</dt><dd>{kind === "component" ? purposeOptions.find((option) => option.value === purpose)?.title : connectionKind.replaceAll("_", " ")}</dd></div><div><dt>{kind === "component" ? "Authority" : "Destination"}</dt><dd>{kind === "component" ? isAction ? "Can change customer data · approval required" : "Read-only" : targetMode === "url" ? baseUrl : serviceBinding}</dd></div><div><dt>{kind === "component" ? "Runtime" : "Access"}</dt><dd>{kind === "component" ? needsConnection ? activeConnections.find((connection) => connection.activeVersionId === connectionVersionId)?.name || "No connection" : implementation.replaceAll("_", " ") : `${methods} · ${paths}`}</dd></div></dl>{kind === "connection" && needsSecret ? <div className="registry-secret-next"><strong>Install the credential after creation</strong><p>From the Resolve project directory, run this command and paste the secret value when Wrangler asks. The value is never entered in this UI.</p><code>npx wrangler secret put {secretReference}</code></div> : null}<div className="registry-review-note"><strong>What happens next</strong><p>{kind === "component" ? "Resolve publishes version 1 and runs its stored contract example. Add the tool to a workflow when you are ready." : `Resolve publishes the connection definition.${needsSecret ? " Install the Worker secret shown above, then" : " Then"} create a tool that uses it and send an explicit live test request from the connection details.`}</p></div></section></> : null}
|
|
276
|
+
{step === 2 ? <><div className="registry-step-heading"><h3>Review and create</h3><p>Confirm the security boundary before publishing version 1.</p></div><section className="registry-review"><dl><div><dt>Name</dt><dd>{name}</dd></div><div><dt>Type</dt><dd>{kind === "component" ? purposeOptions.find((option) => option.value === purpose)?.title : connectionKind.replaceAll("_", " ")}</dd></div><div><dt>{kind === "component" ? "Authority" : "Destination"}</dt><dd>{kind === "component" ? isAction ? "Can change customer data · approval required" : "Read-only" : targetMode === "url" ? baseUrl : serviceBinding}</dd></div><div><dt>{kind === "component" ? "Runtime" : "Access"}</dt><dd>{kind === "component" ? needsConnection ? activeConnections.find((connection) => connection.activeVersionId === connectionVersionId)?.name || "No connection" : implementation.replaceAll("_", " ") : `${methods} · ${paths}`}</dd></div>{kind === "component" && isAction && correctionEnabled ? <div><dt>Appeal correction</dt><dd>{correctionCode} corrects {commaValues(correctsCodes).join(", ")}</dd></div> : null}</dl>{kind === "connection" && needsSecret ? <div className="registry-secret-next"><strong>Install the credential after creation</strong><p>From the Resolve project directory, run this command and paste the secret value when Wrangler asks. The value is never entered in this UI.</p><code>npx wrangler secret put {secretReference}</code></div> : null}<div className="registry-review-note"><strong>What happens next</strong><p>{kind === "component" ? isAction && correctionEnabled ? "Resolve publishes version 1 and checks its stored contract example. Independent appeal reviewers can then approve this correction for the original action types listed above." : "Resolve publishes version 1 and runs its stored contract example. Add the tool to a workflow when you are ready." : `Resolve publishes the connection definition.${needsSecret ? " Install the Worker secret shown above, then" : " Then"} create a tool that uses it and send an explicit live test request from the connection details.`}</p></div></section></> : null}
|
|
248
277
|
</div>
|
|
249
278
|
<div className="editor-footer registry-wizard-footer"><p className="error" role="alert">{error}</p><div>{step === 0 ? <button className="secondary" type="button" disabled={pending} onClick={close}>Cancel</button> : <button className="secondary" type="button" disabled={pending} onClick={back}>Back</button>}{step < 2 ? <button className="primary" type="button" onClick={next}>Continue</button> : <button className="primary" type="submit" disabled={pending}>{pending ? "Creating…" : kind === "component" ? "Create tool" : "Add connection"}</button>}</div></div>
|
|
250
279
|
</form>
|
|
@@ -5,6 +5,7 @@ import { errorMessage, requestJson } from "../lib/http";
|
|
|
5
5
|
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
6
6
|
import { openRegistryEditor, registryEvents } from "./events";
|
|
7
7
|
import { RegistryDetailsDialog } from "./RegistryDetailsDialog";
|
|
8
|
+
import { IntegrationSetup } from "./IntegrationSetup";
|
|
8
9
|
import type { ComponentListResponse, ComponentSummary, ConnectionListResponse, ConnectionSummary, RegistryKind } from "./types";
|
|
9
10
|
|
|
10
11
|
type RegistryItem = ComponentSummary | ConnectionSummary;
|
|
@@ -38,8 +39,10 @@ function RegistryRow({ item, kind, onOpen }: { item: RegistryItem; kind: Registr
|
|
|
38
39
|
const category = component ? toolCategory(component) : "";
|
|
39
40
|
const iconClass = connection ? "connection" : category === "action" ? "action" : category === "ai" ? "agent" : "";
|
|
40
41
|
const icon = connection ? "↗" : category === "action" ? "⚡" : category === "ai" ? "✦" : category === "message" ? "✉" : "+";
|
|
41
|
-
const status =
|
|
42
|
-
|
|
42
|
+
const status = connection && item.status === "active"
|
|
43
|
+
? connection.healthState === "healthy" ? "Last call succeeded" : connection.healthState === "degraded" ? "Recent failures" : connection.healthState === "open_circuit" ? "Temporarily blocked" : connection.healthState === "paused" ? "Paused" : "Not verified yet"
|
|
44
|
+
: lifecycleLabel(item.status || "active");
|
|
45
|
+
const statusClass = item.status === "active" && (!connection || connection.healthState === "healthy") ? "available" : item.status === "archived" ? "archived" : "paused";
|
|
43
46
|
const destination = connection?.baseUrl || connection?.serviceBindingName || "Destination not configured";
|
|
44
47
|
return <article className="registry-resource-row">
|
|
45
48
|
<span className={`object-icon ${iconClass}`} aria-hidden="true">{icon}</span>
|
|
@@ -109,12 +112,14 @@ export function RegistryWorkspace() {
|
|
|
109
112
|
};
|
|
110
113
|
|
|
111
114
|
return <div className="page-stack registry-workspace" data-react-slice="registry-workspace">
|
|
112
|
-
<PageHeader titleId="components-title" title="
|
|
115
|
+
<PageHeader titleId="components-title" title="Connections" description="Connect your customer application and define what Safest can read, request, and confirm.">
|
|
113
116
|
<div className="surface-buttons"><button className="secondary" type="button" disabled={loading || refreshing} onClick={() => void load(true)}>{refreshing ? "Refreshing…" : "Refresh"}</button>{canCreate ? <button className="primary" type="button" onClick={() => openRegistryEditor(currentKind)}>{tab === "tools" ? "Create tool" : "Add connection"}</button> : null}</div>
|
|
114
117
|
</PageHeader>
|
|
115
118
|
|
|
116
|
-
|
|
117
|
-
|
|
119
|
+
{canManage?<IntegrationSetup onTools={kind=>{setTab("tools");setFilter(kind);setSearch("");document.querySelector(".registry-tabs-row")?.scrollIntoView({block:"start",behavior:"smooth"});}} />:null}
|
|
120
|
+
|
|
121
|
+
<section className="registry-model" aria-label="How playbooks use customer capabilities">
|
|
122
|
+
<div><span>1</span><strong>Playbook</strong><small>Defines the permitted work</small></div><b aria-hidden="true">→</b><div><span>2</span><strong>Tool</strong><small>Reads context or takes an action</small></div><b aria-hidden="true">→</b><div><span>3</span><strong>Connection</strong><small>Secures access to a service</small></div><b aria-hidden="true">→</b><div><span>4</span><strong>Your service</strong><small>Returns data or applies a change</small></div>
|
|
118
123
|
</section>
|
|
119
124
|
|
|
120
125
|
<div className="registry-tabs-row"><div className="segmented-tabs" role="tablist" aria-label="Tools and connections"><button className={tab === "tools" ? "active" : ""} type="button" role="tab" aria-selected={tab === "tools"} onClick={() => { setTab("tools"); setSearch(""); }}>Tools <span>{components.filter((item) => item.status !== "archived").length}</span></button><button className={tab === "connections" ? "active" : ""} type="button" role="tab" aria-selected={tab === "connections"} onClick={() => { setTab("connections"); setSearch(""); }}>Connections <span>{connections.filter((item) => item.status !== "archived").length}</span></button></div><p>{tab === "tools" ? "Reusable capabilities placed in workflows. Enrichments are read-only; actions require approval." : "Reusable, allowlisted routes to APIs and Workers. Credential values stay in Worker secrets."}</p></div>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
.integration-verification { min-width: 0; padding: 24px; }
|
|
2
|
+
.integration-verification > .surface-head { padding: 0 0 20px; margin-bottom: 20px; }
|
|
3
|
+
.verification-start { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 16px; }
|
|
4
|
+
.verification-start label, .verification-bind label, .verification-history { display: grid; gap: 8px; min-width: 0; font-weight: 600; font-size: .875rem; }
|
|
5
|
+
.verification-start select, .verification-bind select, .verification-history select { width: 100%; min-width: 0; min-height: 44px; font-size: 1rem; }
|
|
6
|
+
.verification-start .form-help { grid-column: 1 / -1; margin: 0; }
|
|
7
|
+
.verification-start button { justify-self: start; }
|
|
8
|
+
.verification-history, .verification-run { margin-top: 24px; }
|
|
9
|
+
.verification-run { border-top: 1px solid var(--border, #d8e0da); padding-top: 20px; }
|
|
10
|
+
.verification-summary h3, .verification-summary p { margin: 0 0 8px; overflow-wrap: anywhere; }
|
|
11
|
+
.verification-summary small { display: block; margin-bottom: 16px; }
|
|
12
|
+
.verification-state { display: inline-block; border: 1px solid currentColor; border-radius: 6px; padding: 4px 8px; margin: 4px 0 12px; }
|
|
13
|
+
.verification-state-verified { color: #235a43; }
|
|
14
|
+
.verification-state-blocked, .verification-state-stale { color: #8a391c; }
|
|
15
|
+
.verification-bind { display: grid; gap: 12px; margin: 16px 0; }
|
|
16
|
+
.verification-bind p { margin: 0; }
|
|
17
|
+
.verification-bind button { justify-self: start; }
|
|
18
|
+
.verification-evidence { list-style: none; padding: 0; margin: 20px 0; display: grid; gap: 12px; }
|
|
19
|
+
.verification-evidence li { border: 1px solid var(--border, #d8e0da); border-radius: 8px; padding: 14px; }
|
|
20
|
+
.verification-evidence li > div { display: flex; gap: 8px 16px; justify-content: space-between; flex-wrap: wrap; }
|
|
21
|
+
.verification-evidence li span { font-size: .875em; }
|
|
22
|
+
.verification-evidence li p { margin: 8px 0 0; overflow-wrap: anywhere; }
|
|
23
|
+
.verification-evidence li[data-state=complete] { border-left: 4px solid #377457; }
|
|
24
|
+
.verification-evidence li[data-state=blocked] { border-left: 4px solid #a64a25; }
|
|
25
|
+
@media(max-width: 600px) { .integration-verification { padding: 16px; } .integration-verification > .surface-head { align-items: stretch; flex-direction: column; gap: 16px; } .verification-start { grid-template-columns: minmax(0,1fr); } .verification-start button, .verification-bind button { width: 100%; } .verification-evidence li { padding: 12px; } }
|