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,230 @@
|
|
|
1
|
+
import type { AnalystIdentityData } from "../components/AnalystIdentity";
|
|
2
|
+
|
|
3
|
+
export type ReportPriorityFilter = "" | "high" | "medium" | "low";
|
|
4
|
+
export type ReportAgeFilter = "" | "hour" | "day" | "older";
|
|
5
|
+
export type ReportSort = "oldest" | "newest" | "priority";
|
|
6
|
+
|
|
7
|
+
export interface ReportFilters {
|
|
8
|
+
search: string;
|
|
9
|
+
queueId: string;
|
|
10
|
+
priority: ReportPriorityFilter;
|
|
11
|
+
age: ReportAgeFilter;
|
|
12
|
+
state: string;
|
|
13
|
+
sort: ReportSort;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ReportQueueOption {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ReportOwner extends AnalystIdentityData {
|
|
22
|
+
reviewerId: string;
|
|
23
|
+
mine: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReportSummary {
|
|
27
|
+
id: string;
|
|
28
|
+
reference: string;
|
|
29
|
+
queueId: string;
|
|
30
|
+
targetType: string;
|
|
31
|
+
targetReference: string;
|
|
32
|
+
reasonCode: string;
|
|
33
|
+
queueName: string;
|
|
34
|
+
state: string;
|
|
35
|
+
priority: number;
|
|
36
|
+
receivedAt: string;
|
|
37
|
+
owner: ReportOwner | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PresenceAnalyst extends AnalystIdentityData {
|
|
41
|
+
actorId: string;
|
|
42
|
+
activity: string;
|
|
43
|
+
activeReportId: string | null;
|
|
44
|
+
activityLabel: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface EvidenceReference {
|
|
48
|
+
reference: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
availability: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface CaseMessage {
|
|
54
|
+
id?: string;
|
|
55
|
+
direction: string;
|
|
56
|
+
body: string;
|
|
57
|
+
senderType: string;
|
|
58
|
+
createdAt: string;
|
|
59
|
+
deliveryState: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface WorkflowRunRecord {
|
|
63
|
+
id: string;
|
|
64
|
+
workflowName?: string;
|
|
65
|
+
workflowKey?: string;
|
|
66
|
+
workflowVersionId?: string;
|
|
67
|
+
workflowVersion?: string | number;
|
|
68
|
+
runKind?: string;
|
|
69
|
+
authorityMode?: string;
|
|
70
|
+
state: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface TimelineEvent {
|
|
74
|
+
id?: string;
|
|
75
|
+
eventType: string;
|
|
76
|
+
code: string;
|
|
77
|
+
actorType?: string;
|
|
78
|
+
createdAt: string;
|
|
79
|
+
details?: Record<string, unknown>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface FindingRecord {
|
|
83
|
+
id?: string;
|
|
84
|
+
findingType: string;
|
|
85
|
+
summary: string;
|
|
86
|
+
componentVersionId?: string;
|
|
87
|
+
expiresAt?: string;
|
|
88
|
+
freshness?: string;
|
|
89
|
+
status?: string;
|
|
90
|
+
data?: unknown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface DecisionRecord {
|
|
94
|
+
id?: string;
|
|
95
|
+
decisionCode: string;
|
|
96
|
+
policyCode?: string;
|
|
97
|
+
rationale?: string;
|
|
98
|
+
makerType: string;
|
|
99
|
+
createdAt: string;
|
|
100
|
+
evidenceReferences?: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DeliveryAction {
|
|
104
|
+
id?: string;
|
|
105
|
+
actionCode: string;
|
|
106
|
+
status: string;
|
|
107
|
+
attemptCount: number;
|
|
108
|
+
errorCode?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface DeliveryNotice {
|
|
112
|
+
id?: string;
|
|
113
|
+
audience: string;
|
|
114
|
+
deliveryState: string;
|
|
115
|
+
updatedAt: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface WorkflowActionRecord {
|
|
119
|
+
id?: string;
|
|
120
|
+
recordKind: string;
|
|
121
|
+
actionCode: string;
|
|
122
|
+
state: string;
|
|
123
|
+
authorityMode?: string;
|
|
124
|
+
attemptCount?: number;
|
|
125
|
+
errorCode?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface AiRunRecord {
|
|
129
|
+
id?: string;
|
|
130
|
+
mode: string;
|
|
131
|
+
status: string;
|
|
132
|
+
model: string;
|
|
133
|
+
promptVersion: string;
|
|
134
|
+
configVersionId: string;
|
|
135
|
+
summary?: string;
|
|
136
|
+
errorCode?: string;
|
|
137
|
+
outcome?: string;
|
|
138
|
+
policyCode?: string;
|
|
139
|
+
estimatedCostMicrousd?: number;
|
|
140
|
+
uncertain?: boolean;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export interface PolicyRule {
|
|
144
|
+
code: string;
|
|
145
|
+
title?: string;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ReportClaim {
|
|
149
|
+
reviewerId: string;
|
|
150
|
+
expiresAt: string;
|
|
151
|
+
analyst?: AnalystIdentityData;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface ReportDetailRecord {
|
|
155
|
+
id: string;
|
|
156
|
+
reference: string;
|
|
157
|
+
state: string;
|
|
158
|
+
queueId?: string;
|
|
159
|
+
reasonCode: string;
|
|
160
|
+
targetType: string;
|
|
161
|
+
targetReference: string;
|
|
162
|
+
customerUrl?: string;
|
|
163
|
+
ownerReference?: string;
|
|
164
|
+
reporterReference?: string;
|
|
165
|
+
policyTitle?: string;
|
|
166
|
+
policyVersionId?: string;
|
|
167
|
+
queuePolicyVersionId?: string;
|
|
168
|
+
reviewerInstructions?: string;
|
|
169
|
+
receivedAt: string;
|
|
170
|
+
policyRules?: PolicyRule[];
|
|
171
|
+
evidence?: EvidenceReference[];
|
|
172
|
+
messages?: CaseMessage[];
|
|
173
|
+
workflowRuns?: WorkflowRunRecord[];
|
|
174
|
+
timeline?: TimelineEvent[];
|
|
175
|
+
findings?: FindingRecord[];
|
|
176
|
+
decisions?: DecisionRecord[];
|
|
177
|
+
actions?: DeliveryAction[];
|
|
178
|
+
notices?: DeliveryNotice[];
|
|
179
|
+
workflowActions?: WorkflowActionRecord[];
|
|
180
|
+
aiRuns?: AiRunRecord[];
|
|
181
|
+
claim?: ReportClaim | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type ReportPendingAction = "claim" | "release" | "message" | "decision" | null;
|
|
185
|
+
|
|
186
|
+
export interface ReportDetailSnapshot {
|
|
187
|
+
reportId: string | null;
|
|
188
|
+
loading: boolean;
|
|
189
|
+
error: string;
|
|
190
|
+
pendingAction: ReportPendingAction;
|
|
191
|
+
messageMutationVersion: number;
|
|
192
|
+
report: ReportDetailRecord | null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export interface ReportPermissions {
|
|
196
|
+
claimReports: boolean;
|
|
197
|
+
messageReporters: boolean;
|
|
198
|
+
decideReports: boolean;
|
|
199
|
+
readRuns: boolean;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface ReportWorkspaceSnapshot {
|
|
203
|
+
loading: boolean;
|
|
204
|
+
refreshing: boolean;
|
|
205
|
+
error: string;
|
|
206
|
+
generatedAt: string;
|
|
207
|
+
actorId: string;
|
|
208
|
+
filters: ReportFilters;
|
|
209
|
+
queues: ReportQueueOption[];
|
|
210
|
+
reports: ReportSummary[];
|
|
211
|
+
presence: PresenceAnalyst[];
|
|
212
|
+
actionCodes: string[];
|
|
213
|
+
permissions: ReportPermissions;
|
|
214
|
+
detail: ReportDetailSnapshot;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export interface ReporterMessageInput {
|
|
218
|
+
body: string;
|
|
219
|
+
awaitReporter: boolean;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface HumanDecisionInput {
|
|
223
|
+
decisionCode: string;
|
|
224
|
+
policyCode: string;
|
|
225
|
+
rationale: string;
|
|
226
|
+
evidenceReferences: string[];
|
|
227
|
+
reporterNotice: string;
|
|
228
|
+
affectedUserNotice: string;
|
|
229
|
+
actionCode: string;
|
|
230
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState, type CSSProperties, type FormEvent } from "react";
|
|
2
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
3
|
+
import type { WorkspaceBrand } from "../shell/types";
|
|
4
|
+
import type { BrandResponse } from "./types";
|
|
5
|
+
|
|
6
|
+
const fallbackBrand: WorkspaceBrand = {
|
|
7
|
+
organization_name: "Safest Resolve",
|
|
8
|
+
logo_url: "",
|
|
9
|
+
logo_alt: "",
|
|
10
|
+
primary_color: "#154e3a",
|
|
11
|
+
primary_contrast_color: "#ffffff",
|
|
12
|
+
accent_color: "#cbef68",
|
|
13
|
+
background_color: "#f5f6f1",
|
|
14
|
+
surface_color: "#ffffff",
|
|
15
|
+
text_color: "#14231c",
|
|
16
|
+
muted_color: "#637168",
|
|
17
|
+
border_color: "#dfe6e0",
|
|
18
|
+
body_font_family: '"Safest Sans", Manrope, ui-sans-serif, system-ui, sans-serif',
|
|
19
|
+
heading_font_family: '"Safest Serif", Newsreader, Georgia, serif',
|
|
20
|
+
font_css_url: "",
|
|
21
|
+
border_radius: 12,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function completeBrand(brand: WorkspaceBrand | null, displayName: string): WorkspaceBrand {
|
|
25
|
+
return {
|
|
26
|
+
...fallbackBrand,
|
|
27
|
+
...(window.SafestBrand?.defaults ?? {}),
|
|
28
|
+
...(brand ?? {}),
|
|
29
|
+
organization_name: brand?.organization_name || displayName || "Safest Resolve",
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function initials(value: string): string {
|
|
34
|
+
return value.split(/\s+/u).filter(Boolean).slice(0, 2).map((part) => part[0] ?? "").join("").toUpperCase() || "YO";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function BrandEditor({ brand, displayName, loading, onSaved }: {
|
|
38
|
+
brand: WorkspaceBrand | null;
|
|
39
|
+
displayName: string;
|
|
40
|
+
loading: boolean;
|
|
41
|
+
onSaved: (brand: WorkspaceBrand) => void;
|
|
42
|
+
}) {
|
|
43
|
+
const resolved = useMemo(() => completeBrand(brand, displayName), [brand, displayName]);
|
|
44
|
+
const [draft, setDraft] = useState<WorkspaceBrand>(resolved);
|
|
45
|
+
const [saving, setSaving] = useState(false);
|
|
46
|
+
const [savedMessage, setSavedMessage] = useState("");
|
|
47
|
+
const [error, setError] = useState("");
|
|
48
|
+
|
|
49
|
+
useEffect(() => { setDraft(resolved); setSavedMessage(""); setError(""); }, [resolved]);
|
|
50
|
+
|
|
51
|
+
const dirty = JSON.stringify(draft) !== JSON.stringify(resolved);
|
|
52
|
+
const update = <Key extends keyof WorkspaceBrand>(key: Key, value: WorkspaceBrand[Key]): void => {
|
|
53
|
+
setDraft((current) => ({ ...current, [key]: value }));
|
|
54
|
+
setSavedMessage("");
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const save = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
setSaving(true);
|
|
60
|
+
setError("");
|
|
61
|
+
setSavedMessage("");
|
|
62
|
+
try {
|
|
63
|
+
const result = await requestJson<BrandResponse>("/v1/admin/brand", {
|
|
64
|
+
method: "PATCH",
|
|
65
|
+
headers: mutationHeaders(),
|
|
66
|
+
body: JSON.stringify({ brand: draft }),
|
|
67
|
+
});
|
|
68
|
+
setDraft(result.brand);
|
|
69
|
+
onSaved(result.brand);
|
|
70
|
+
window.SafestBrand?.applyBrand(result.brand, { title: "Settings" });
|
|
71
|
+
setSavedMessage("Branding saved across the workspace.");
|
|
72
|
+
} catch (cause) {
|
|
73
|
+
setError(errorMessage(cause, "Branding could not be saved."));
|
|
74
|
+
} finally {
|
|
75
|
+
setSaving(false);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const previewStyle = {
|
|
80
|
+
"--preview-primary": draft.primary_color,
|
|
81
|
+
"--preview-primary-text": draft.primary_contrast_color,
|
|
82
|
+
"--preview-bg": draft.background_color,
|
|
83
|
+
"--preview-surface": draft.surface_color,
|
|
84
|
+
"--preview-text": draft.text_color,
|
|
85
|
+
"--preview-muted": draft.muted_color,
|
|
86
|
+
"--preview-line": draft.border_color,
|
|
87
|
+
"--preview-radius": `${draft.border_radius}px`,
|
|
88
|
+
"--preview-body": draft.body_font_family || "system-ui",
|
|
89
|
+
"--preview-heading": draft.heading_font_family || "Georgia",
|
|
90
|
+
} as CSSProperties;
|
|
91
|
+
const logoStyle = draft.logo_url
|
|
92
|
+
? ({ backgroundImage: `url("${draft.logo_url.replaceAll('"', "%22")}")` } as CSSProperties)
|
|
93
|
+
: undefined;
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<section className="surface settings-card settings-wide brand-settings-card" aria-labelledby="branding-title">
|
|
97
|
+
<div className="surface-head">
|
|
98
|
+
<div><h2 id="branding-title">Branding</h2><p>Your logo and colours appear in the widget, report form, emails, and console.</p></div>
|
|
99
|
+
<span className={`badge${dirty ? " urgent" : ""}`}>{loading ? "Loading" : dirty ? "Unsaved changes" : "Saved"}</span>
|
|
100
|
+
</div>
|
|
101
|
+
{loading ? <div className="settings-card-loading"><div className="skeleton-block" /></div> : (
|
|
102
|
+
<form className="brand-form" onSubmit={(event) => void save(event)}>
|
|
103
|
+
<div className="brand-controls">
|
|
104
|
+
<label>Organisation name<input maxLength={120} required value={draft.organization_name} onChange={(event) => update("organization_name", event.currentTarget.value)} /></label>
|
|
105
|
+
<label>Logo URL<input type="url" maxLength={2048} placeholder="https://cdn.example.com/logo.svg" value={draft.logo_url} onChange={(event) => update("logo_url", event.currentTarget.value)} /></label>
|
|
106
|
+
<label>Logo alt text<input maxLength={160} placeholder="Example Company logo" value={draft.logo_alt} onChange={(event) => update("logo_alt", event.currentTarget.value)} /></label>
|
|
107
|
+
<label>Primary colour<input type="color" value={draft.primary_color} onChange={(event) => update("primary_color", event.currentTarget.value)} /></label>
|
|
108
|
+
<label>Text on primary<input type="color" value={draft.primary_contrast_color} onChange={(event) => update("primary_contrast_color", event.currentTarget.value)} /></label>
|
|
109
|
+
<label>Accent colour<input type="color" value={draft.accent_color} onChange={(event) => update("accent_color", event.currentTarget.value)} /></label>
|
|
110
|
+
<label>Page background<input type="color" value={draft.background_color} onChange={(event) => update("background_color", event.currentTarget.value)} /></label>
|
|
111
|
+
<label>Surface colour<input type="color" value={draft.surface_color} onChange={(event) => update("surface_color", event.currentTarget.value)} /></label>
|
|
112
|
+
<label>Text colour<input type="color" value={draft.text_color} onChange={(event) => update("text_color", event.currentTarget.value)} /></label>
|
|
113
|
+
<label>Muted text<input type="color" value={draft.muted_color} onChange={(event) => update("muted_color", event.currentTarget.value)} /></label>
|
|
114
|
+
<label>Border colour<input type="color" value={draft.border_color} onChange={(event) => update("border_color", event.currentTarget.value)} /></label>
|
|
115
|
+
<label>Corner roundness<input type="range" min={0} max={32} step={1} value={draft.border_radius} onChange={(event) => update("border_radius", Number(event.currentTarget.value))} /><output>{draft.border_radius}px</output></label>
|
|
116
|
+
<label>Body font family<input maxLength={180} placeholder="Inter, system-ui, sans-serif" value={draft.body_font_family} onChange={(event) => update("body_font_family", event.currentTarget.value)} /></label>
|
|
117
|
+
<label>Heading font family<input maxLength={180} placeholder="Georgia, serif" value={draft.heading_font_family} onChange={(event) => update("heading_font_family", event.currentTarget.value)} /></label>
|
|
118
|
+
<label className="wide-field">Web font stylesheet <small>Optional. Use an HTTPS stylesheet from your font provider.</small><input type="url" maxLength={2048} placeholder="https://fonts.example.com/brand.css" value={draft.font_css_url} onChange={(event) => update("font_css_url", event.currentTarget.value)} /></label>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="brand-preview"><p className="preview-label">Preview</p><div className="brand-preview-card" style={previewStyle}><span className={`brand-preview-logo${draft.logo_url ? " has-image" : ""}`} style={logoStyle}>{initials(draft.organization_name)}</span><div><h3>{draft.organization_name || "Your organisation"}</h3><p>Report a safety concern</p><button type="button">Submit report</button></div></div></div>
|
|
121
|
+
<div className="editor-footer"><div className="settings-form-feedback"><p className="form-message" role="status">{savedMessage}</p><p className="error" role="alert">{error}</p></div><button className="primary" type="submit" disabled={!dirty || saving}>{saving ? "Saving…" : "Save branding"}</button></div>
|
|
122
|
+
</form>
|
|
123
|
+
)}
|
|
124
|
+
</section>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { createElement, useEffect, useRef, useState, type FormEvent } from "react";
|
|
2
|
+
import { errorMessage, mutationHeaders, requestJson } from "../lib/http";
|
|
3
|
+
import { getShellSnapshot, subscribeToShell } from "../shell/store";
|
|
4
|
+
import { useSyncExternalStore } from "react";
|
|
5
|
+
import { dispatchChannelsChanged, settingsEvents } from "./events";
|
|
6
|
+
import { widgetHandoff } from "./handoff";
|
|
7
|
+
import type {
|
|
8
|
+
ChannelMode,
|
|
9
|
+
ContextField,
|
|
10
|
+
ContextFieldSensitivity,
|
|
11
|
+
ContextFieldType,
|
|
12
|
+
ContextSchema,
|
|
13
|
+
HostedChannelResponse,
|
|
14
|
+
PublishedForm,
|
|
15
|
+
PublishedFormsResponse,
|
|
16
|
+
WidgetChannelResponse,
|
|
17
|
+
} from "./types";
|
|
18
|
+
|
|
19
|
+
type ChannelResult =
|
|
20
|
+
| { kind: "hosted"; url: string }
|
|
21
|
+
| {
|
|
22
|
+
kind: "widget";
|
|
23
|
+
mode: "contextual" | "anonymous";
|
|
24
|
+
apiKey: string;
|
|
25
|
+
handoff: string;
|
|
26
|
+
publicBaseUrl: string;
|
|
27
|
+
integrationId: string;
|
|
28
|
+
formVersion: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function newContextField(): ContextField {
|
|
32
|
+
return { id: crypto.randomUUID(), key: "", label: "", type: "string", sensitivity: "standard", required: false, rule_eligible: true };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function integrationKey(name: string): string {
|
|
36
|
+
const base = name.toLowerCase().normalize("NFKD").replace(/[^a-z0-9]+/gu, "-").replace(/^-+|-+$/gu, "").slice(0, 60) || "channel";
|
|
37
|
+
return `${base}-${crypto.randomUUID().slice(0, 6)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function copyText(value: string): Promise<void> {
|
|
41
|
+
try {
|
|
42
|
+
await navigator.clipboard.writeText(value);
|
|
43
|
+
} catch {
|
|
44
|
+
const input = document.createElement("textarea");
|
|
45
|
+
input.value = value;
|
|
46
|
+
input.style.position = "fixed";
|
|
47
|
+
input.style.opacity = "0";
|
|
48
|
+
document.body.append(input);
|
|
49
|
+
input.select();
|
|
50
|
+
document.execCommand("copy");
|
|
51
|
+
input.remove();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function contextSchema(fields: ContextField[]): ContextSchema {
|
|
56
|
+
const clean = fields.map((field) => ({
|
|
57
|
+
key: field.key.trim(),
|
|
58
|
+
label: field.label.trim(),
|
|
59
|
+
type: field.type,
|
|
60
|
+
sensitivity: field.sensitivity,
|
|
61
|
+
required: field.required,
|
|
62
|
+
rule_eligible: field.rule_eligible,
|
|
63
|
+
}));
|
|
64
|
+
if (clean.some((field) => !/^[A-Za-z][A-Za-z0-9_-]{0,63}$/u.test(field.key) || !field.label)) {
|
|
65
|
+
throw new Error("Each trusted field needs a simple key and a clear label.");
|
|
66
|
+
}
|
|
67
|
+
if (new Set(clean.map((field) => field.key)).size !== clean.length) throw new Error("Trusted field keys must be unique.");
|
|
68
|
+
return { version: 1, allow_unknown: false, fields: clean };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function ChannelDialog() {
|
|
72
|
+
const shell = useSyncExternalStore(subscribeToShell, getShellSnapshot, getShellSnapshot);
|
|
73
|
+
const canManageIntegrations = shell.session?.permissions.manageIntegrations === true;
|
|
74
|
+
const canReadForms = shell.session?.permissions.manageConfiguration === true;
|
|
75
|
+
const dialogRef = useRef<HTMLDialogElement>(null);
|
|
76
|
+
const formSelectRef = useRef<HTMLSelectElement>(null);
|
|
77
|
+
const [forms, setForms] = useState<PublishedForm[]>([]);
|
|
78
|
+
const [formsLoading, setFormsLoading] = useState(false);
|
|
79
|
+
const [mode, setMode] = useState<ChannelMode>("hosted");
|
|
80
|
+
const [formVersion, setFormVersion] = useState("");
|
|
81
|
+
const [name, setName] = useState("");
|
|
82
|
+
const [origin, setOrigin] = useState("");
|
|
83
|
+
const [fields, setFields] = useState<ContextField[]>([]);
|
|
84
|
+
const [pending, setPending] = useState(false);
|
|
85
|
+
const [error, setError] = useState("");
|
|
86
|
+
const [result, setResult] = useState<ChannelResult | null>(null);
|
|
87
|
+
const [revealKey, setRevealKey] = useState(false);
|
|
88
|
+
const [copied, setCopied] = useState("");
|
|
89
|
+
|
|
90
|
+
const clearSensitiveState = (): void => {
|
|
91
|
+
setResult(null);
|
|
92
|
+
setRevealKey(false);
|
|
93
|
+
setCopied("");
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
useEffect(() => {
|
|
97
|
+
const open = (): void => {
|
|
98
|
+
if (!canManageIntegrations) return;
|
|
99
|
+
setMode("hosted");
|
|
100
|
+
setFormVersion("");
|
|
101
|
+
setName("");
|
|
102
|
+
setOrigin("");
|
|
103
|
+
setFields([]);
|
|
104
|
+
setError("");
|
|
105
|
+
clearSensitiveState();
|
|
106
|
+
setForms([]);
|
|
107
|
+
const dialog = dialogRef.current;
|
|
108
|
+
if (dialog && !dialog.open) dialog.showModal();
|
|
109
|
+
if (!canReadForms) {
|
|
110
|
+
setError("You also need configuration access to choose a published report form.");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
setFormsLoading(true);
|
|
114
|
+
void requestJson<PublishedFormsResponse>("/v1/admin/forms/published")
|
|
115
|
+
.then((response) => {
|
|
116
|
+
const published = Array.isArray(response.forms) ? response.forms : [];
|
|
117
|
+
setForms(published);
|
|
118
|
+
setFormVersion(published[0]?.id ?? "");
|
|
119
|
+
})
|
|
120
|
+
.catch((cause) => setError(errorMessage(cause, "Published forms could not be loaded.")))
|
|
121
|
+
.finally(() => setFormsLoading(false));
|
|
122
|
+
};
|
|
123
|
+
window.addEventListener(settingsEvents.openChannel, open);
|
|
124
|
+
return () => window.removeEventListener(settingsEvents.openChannel, open);
|
|
125
|
+
}, [canManageIntegrations, canReadForms]);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (formsLoading || !forms.length || !dialogRef.current?.open) return;
|
|
129
|
+
const frame = window.requestAnimationFrame(() => formSelectRef.current?.focus());
|
|
130
|
+
return () => window.cancelAnimationFrame(frame);
|
|
131
|
+
}, [formsLoading, forms.length]);
|
|
132
|
+
|
|
133
|
+
const close = (): void => { if (!pending) dialogRef.current?.close(); };
|
|
134
|
+
const changeField = (id: string, update: Partial<ContextField>): void => {
|
|
135
|
+
setFields((current) => current.map((field) => field.id === id ? { ...field, ...update } : field));
|
|
136
|
+
};
|
|
137
|
+
const copy = async (value: string, key: string): Promise<void> => {
|
|
138
|
+
await copyText(value);
|
|
139
|
+
setCopied(key);
|
|
140
|
+
window.setTimeout(() => setCopied((current) => current === key ? "" : current), 1_500);
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const create = async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
|
144
|
+
event.preventDefault();
|
|
145
|
+
setError("");
|
|
146
|
+
if (!formVersion) { setError("Publish an intake form before adding a reporting channel."); return; }
|
|
147
|
+
setPending(true);
|
|
148
|
+
try {
|
|
149
|
+
if (mode === "hosted") {
|
|
150
|
+
const response = await requestJson<HostedChannelResponse>("/v1/admin/reporting-channels", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: mutationHeaders(),
|
|
153
|
+
body: JSON.stringify({ mode: "hosted", form_version_id: formVersion }),
|
|
154
|
+
});
|
|
155
|
+
setResult({ kind: "hosted", url: response.channel.url });
|
|
156
|
+
} else {
|
|
157
|
+
let website: URL;
|
|
158
|
+
try { website = new URL(origin); }
|
|
159
|
+
catch { throw new Error("Enter the complete website address, including https://."); }
|
|
160
|
+
const local = ["localhost", "127.0.0.1", "[::1]"].includes(website.hostname);
|
|
161
|
+
if ((website.protocol !== "https:" && !(local && website.protocol === "http:")) || website.username || website.password) {
|
|
162
|
+
throw new Error("Use an HTTPS website origin. Localhost may use HTTP for development.");
|
|
163
|
+
}
|
|
164
|
+
const schema = mode === "contextual" ? contextSchema(fields) : { version: 1, allow_unknown: false, fields: [] } satisfies ContextSchema;
|
|
165
|
+
const response = await requestJson<WidgetChannelResponse>("/v1/admin/integrations", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: mutationHeaders(),
|
|
168
|
+
body: JSON.stringify({
|
|
169
|
+
integration_key: integrationKey(name),
|
|
170
|
+
name,
|
|
171
|
+
allowed_origins: [website.origin],
|
|
172
|
+
scopes: mode === "contextual" ? ["submit_reports", "mint_context_tokens"] : ["submit_reports"],
|
|
173
|
+
channel_mode: mode,
|
|
174
|
+
form_version_id: formVersion,
|
|
175
|
+
context_schema: schema,
|
|
176
|
+
}),
|
|
177
|
+
});
|
|
178
|
+
setResult({
|
|
179
|
+
kind: "widget",
|
|
180
|
+
mode,
|
|
181
|
+
apiKey: response.apiKey ?? "",
|
|
182
|
+
handoff: widgetHandoff(response, website.origin, formVersion, mode, schema),
|
|
183
|
+
publicBaseUrl: response.publicBaseUrl || window.location.origin,
|
|
184
|
+
integrationId: response.integrationId,
|
|
185
|
+
formVersion,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
dispatchChannelsChanged();
|
|
189
|
+
} catch (cause) {
|
|
190
|
+
setError(errorMessage(cause, "The reporting channel could not be created."));
|
|
191
|
+
} finally {
|
|
192
|
+
setPending(false);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const widgetResult = result?.kind === "widget" ? result : null;
|
|
197
|
+
|
|
198
|
+
return (
|
|
199
|
+
<dialog ref={dialogRef} className="editor-dialog integration-dialog" onCancel={(event) => { if (pending) event.preventDefault(); }} onClose={clearSensitiveState} data-react-slice="channel-dialog">
|
|
200
|
+
<div className="detail-head"><div><h2>Add reporting channel</h2><p>Choose how people will open and submit the report form.</p></div><button className="icon-button" type="button" aria-label="Close" disabled={pending} onClick={close}>×</button></div>
|
|
201
|
+
<form className="integration-wizard" onSubmit={(event) => void create(event)}>
|
|
202
|
+
{!result ? <div className="integration-setup-step">
|
|
203
|
+
<label>Published report form<select ref={formSelectRef} required disabled={formsLoading || !forms.length} value={formVersion} onChange={(event) => setFormVersion(event.currentTarget.value)}>{formsLoading ? <option value="">Loading forms…</option> : forms.length ? forms.map((form) => <option key={form.id} value={form.id}>{form.title} · v{form.version}</option>) : <option value="">Publish an intake form first</option>}</select></label>
|
|
204
|
+
<fieldset><legend>Where will people report?</legend>
|
|
205
|
+
<label className="channel-choice"><input type="radio" name="channel-mode" value="hosted" checked={mode === "hosted"} onChange={() => setMode("hosted")} /><span><strong>Hosted report form</strong><small>Share a link. Reporters enter their email so you can reply, but their identity is not verified.</small></span></label>
|
|
206
|
+
<label className="channel-choice"><input type="radio" name="channel-mode" value="contextual" checked={mode === "contextual"} onChange={() => setMode("contextual")} /><span><strong>Signed-in product widget</strong><small>Your backend verifies the reporter, the item being reported, and any trusted facts.</small></span></label>
|
|
207
|
+
<label className="channel-choice"><input type="radio" name="channel-mode" value="anonymous" checked={mode === "anonymous"} onChange={() => setMode("anonymous")} /><span><strong>Anonymous product widget</strong><small>No backend setup. Reporters provide an email for replies; page context remains unverified.</small></span></label>
|
|
208
|
+
</fieldset>
|
|
209
|
+
{mode !== "hosted" ? <>
|
|
210
|
+
<label>Channel name<input maxLength={120} minLength={2} required placeholder="Customer web app" value={name} onChange={(event) => setName(event.currentTarget.value)} /></label>
|
|
211
|
+
<label>Website origin <small>Only this exact website may display the widget.</small><input type="url" required placeholder="https://app.example.com" value={origin} onChange={(event) => setOrigin(event.currentTarget.value)} /></label>
|
|
212
|
+
</> : null}
|
|
213
|
+
{mode === "contextual" ? <fieldset>
|
|
214
|
+
<legend>Trusted context your backend may send</legend>
|
|
215
|
+
<p className="field-help">Optional. Register only facts that reviewers or rules need. Reporter identity and the report target are handled separately.</p>
|
|
216
|
+
<div className="context-field-list">{fields.map((field) => <div className="context-field-row" key={field.id}>
|
|
217
|
+
<input className="context-field-key" aria-label="Field key" maxLength={64} placeholder="account_age_days" value={field.key} onChange={(event) => changeField(field.id, { key: event.currentTarget.value })} />
|
|
218
|
+
<input className="context-field-label" aria-label="Field label" maxLength={120} placeholder="Account age in days" value={field.label} onChange={(event) => changeField(field.id, { label: event.currentTarget.value })} />
|
|
219
|
+
<select aria-label="Field type" value={field.type} onChange={(event) => changeField(field.id, { type: event.currentTarget.value as ContextFieldType })}><option value="string">Text</option><option value="number">Number</option><option value="boolean">Yes / no</option></select>
|
|
220
|
+
<select aria-label="Field sensitivity" value={field.sensitivity} onChange={(event) => changeField(field.id, { sensitivity: event.currentTarget.value as ContextFieldSensitivity })}><option value="standard">Standard</option><option value="sensitive">Sensitive</option></select>
|
|
221
|
+
<label className="context-field-check"><input type="checkbox" checked={field.required} onChange={(event) => changeField(field.id, { required: event.currentTarget.checked })} />Required</label>
|
|
222
|
+
<label className="context-field-check"><input type="checkbox" checked={field.rule_eligible} onChange={(event) => changeField(field.id, { rule_eligible: event.currentTarget.checked })} />May be used in rules</label>
|
|
223
|
+
<button className="icon-button context-field-remove" type="button" aria-label="Remove trusted field" onClick={() => setFields((current) => current.filter((candidate) => candidate.id !== field.id))}>×</button>
|
|
224
|
+
</div>)}</div>
|
|
225
|
+
<button className="secondary compact-button" type="button" onClick={() => setFields((current) => [...current, newContextField()])}>Add trusted field</button>
|
|
226
|
+
</fieldset> : null}
|
|
227
|
+
<div className="editor-footer"><p className="error" role="alert">{error}</p><button className="primary" type="submit" disabled={pending || formsLoading || !forms.length}>{pending ? "Creating…" : "Create channel"}</button></div>
|
|
228
|
+
</div> : <div className="integration-result">
|
|
229
|
+
<div className="success-heading"><span>✓</span><div><h3>{result.kind === "hosted" ? "Hosted report form ready" : result.mode === "contextual" ? "Signed-in widget ready" : "Anonymous widget ready"}</h3><p>{result.kind === "hosted" ? "Share this link or add it to your website navigation. No code or API key is required." : result.mode === "contextual" ? "Your developer embeds one component and adds a small backend endpoint for verified identity and trusted context." : "The widget is ready without a backend secret. Reporter identity and page-supplied context remain unverified."}</p></div></div>
|
|
230
|
+
{result.kind === "hosted" ? <div className="channel-result-block"><label>Hosted report link<div className="copy-control"><input readOnly value={result.url} /><button className="secondary" type="button" onClick={() => void copy(result.url, "url")}>{copied === "url" ? "Copied" : "Copy link"}</button></div></label><a className="primary link-button" href={result.url} target="_blank" rel="noopener">Open report form</a></div> : null}
|
|
231
|
+
{widgetResult ? <div className="channel-result-block">
|
|
232
|
+
{widgetResult.mode === "contextual" ? <div className="secret-callout"><strong>One-time backend key</strong><p>Store this in your backend secret manager now. It must never be placed in the page or sent to a browser.</p><div className="copy-control"><input type={revealKey ? "text" : "password"} readOnly value={widgetResult.apiKey} aria-label="One-time backend key" /><button className="secondary" type="button" onClick={() => setRevealKey((value) => !value)}>{revealKey ? "Hide" : "Show"}</button><button className="secondary" type="button" onClick={() => void copy(widgetResult.apiKey, "key")}>{copied === "key" ? "Copied" : "Copy"}</button></div></div> : null}
|
|
233
|
+
<label>Developer handoff<div className="copy-control"><textarea readOnly value={widgetResult.handoff} /><button className="secondary" type="button" onClick={() => void copy(widgetResult.handoff, "handoff")}>{copied === "handoff" ? "Copied" : "Copy handoff"}</button></div></label>
|
|
234
|
+
<details><summary>Preview generated widget</summary><div className="integration-widget-preview">{createElement("safest-report", { preview: "", "api-base": widgetResult.publicBaseUrl, "integration-id": widgetResult.integrationId, "form-version": widgetResult.formVersion })}</div></details>
|
|
235
|
+
</div> : null}
|
|
236
|
+
<div className="editor-footer"><span /><button className="primary" type="button" onClick={close}>Done</button></div>
|
|
237
|
+
</div>}
|
|
238
|
+
</form>
|
|
239
|
+
</dialog>
|
|
240
|
+
);
|
|
241
|
+
}
|