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
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"release":"a5f252eca115ed4eac3c"}
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
.appeal-dialog>.detail-head{background:var(--paper,#fff)}.appeal-correction-card{border:1px solid var(--line,#d8dfdc);border-radius:12px;min-width:0;margin-block:16px;padding:16px}.appeal-correction-form{gap:16px;min-width:0;display:grid}.appeal-correction-form label:not(.appeal-correction-approval){gap:6px;font-size:.875rem;display:grid}.appeal-correction-form input:not([type=checkbox]),.appeal-correction-form select,.appeal-correction-form textarea{box-sizing:border-box;width:100%;min-width:0;max-width:100%;min-height:44px;font-size:1rem}.appeal-correction-form textarea{min-height:96px}.appeal-correction-approval{align-items:flex-start;gap:10px;font-weight:400;display:flex}.appeal-correction-approval input{flex:0 0 20px;width:20px;height:20px;margin-top:2px}.appeal-correction-card dd,.appeal-operation-reference{overflow-wrap:anywhere}.appeal-operation-reference{font-size:.875rem}.appeal-correction-progress h5{margin-block:16px 8px;font-size:1rem}@media (width<=600px){.appeal-correction-card{padding:12px}.appeal-correction-form button{white-space:normal;width:100%;min-height:44px}}.appeal-correction-card .appeal-target-summary{min-width:0;margin-block:16px}.appeal-correction-card .appeal-target-fields{margin:0;display:block}.appeal-correction-card .appeal-target-fields>div{min-width:0;margin-block:0 12px;display:block}.appeal-correction-card .appeal-target-fields dt{float:none;color:var(--muted,#5c6862);margin:0 0 4px;font-size:.8125rem;display:block}.appeal-correction-card .appeal-target-fields dd{overflow-wrap:anywhere;word-break:normal;min-width:0;max-width:100%;margin:0;font-size:.9375rem;line-height:1.5;display:block}.appeal-target-link{flex-wrap:wrap;align-items:baseline;gap:4px 10px;min-width:0;margin-block:12px;display:flex}.appeal-target-link a{align-items:center;min-height:44px;display:inline-flex}.appeal-target-link>span{color:var(--muted,#5c6862);overflow-wrap:anywhere;min-width:0;font-size:.8125rem}.appeal-target-facts summary,.appeal-operation-details summary{box-sizing:border-box;cursor:pointer;min-height:44px;margin-inline-start:1.1em;padding-block:10px;display:list-item}.appeal-target-facts[open] summary{margin-bottom:8px}.appeal-correction-action{margin-top:20px;font-size:1rem}.appeal-operation-details{margin-block:8px;font-size:.875rem}.appeal-operation-details p{margin-block:4px 12px}.case-escalations{min-width:0;margin-block:1rem;padding:1.25rem}.case-escalations .surface-head{flex-wrap:wrap;gap:1rem;margin-bottom:1rem;padding:0 0 1rem}.case-escalations p{color:var(--muted,#5c6862);line-height:1.5}.case-escalation-list{gap:1rem;display:grid}.case-escalation-item{border:1px solid var(--line,#d8dfda);overflow-wrap:anywhere;border-radius:.75rem;min-width:0;padding:1rem}.case-escalation-item-head{flex-wrap:wrap;justify-content:space-between;align-items:flex-start;gap:.75rem;display:flex}.case-escalation-item h3{margin:0;font-size:1rem}.case-escalation-item-head p{margin:.3rem 0 0;font-size:.875rem}.case-escalation-item dl{grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;display:grid}.case-escalation-item dt{color:var(--muted,#5c6862);font-size:.8rem}.case-escalation-item dd{margin:.2rem 0 0;font-size:.9rem}.case-escalation-buttons{flex-wrap:wrap;align-items:center;gap:.5rem;display:flex}.case-escalation-buttons button{white-space:normal;min-height:44px}.case-escalation-warning{border-inline-start:3px solid #af7820;padding-inline-start:.75rem}.case-escalation-settings{border:1px solid var(--line,#d8dfda);border-radius:.75rem;margin-block:1rem;padding:1rem}.case-escalation-settings label{gap:.35rem;min-width:0;font-size:.875rem;display:grid}.case-escalation-setting-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:.75rem;margin-block:.75rem;display:grid}.case-escalation-settings select,.case-escalation-settings input,.case-escalation-settings textarea{width:100%;min-width:0;min-height:44px;font-size:1rem}.case-escalation-status{background:var(--brand-soft,#e8f1e9);border-radius:.5rem;padding:.75rem}@media (width<=560px){.case-escalations{padding:1rem}.case-escalation-setting-grid{grid-template-columns:minmax(0,1fr)}.case-escalation-buttons{align-items:stretch}.case-escalation-buttons button{flex:auto}}.integration-verification{min-width:0;padding:24px}.integration-verification>.surface-head{margin-bottom:20px;padding:0 0 20px}.verification-start{grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;display:grid}.verification-start label,.verification-bind label,.verification-history{gap:8px;min-width:0;font-size:.875rem;font-weight:600;display:grid}.verification-start select,.verification-bind select,.verification-history select{width:100%;min-width:0;min-height:44px;font-size:1rem}.verification-start .form-help{grid-column:1/-1;margin:0}.verification-start button{justify-self:start}.verification-history,.verification-run{margin-top:24px}.verification-run{border-top:1px solid var(--border,#d8e0da);padding-top:20px}.verification-summary h3,.verification-summary p{overflow-wrap:anywhere;margin:0 0 8px}.verification-summary small{margin-bottom:16px;display:block}.verification-state{border:1px solid;border-radius:6px;margin:4px 0 12px;padding:4px 8px;display:inline-block}.verification-state-verified{color:#235a43}.verification-state-blocked,.verification-state-stale{color:#8a391c}.verification-bind{gap:12px;margin:16px 0;display:grid}.verification-bind p{margin:0}.verification-bind button{justify-self:start}.verification-evidence{gap:12px;margin:20px 0;padding:0;list-style:none;display:grid}.verification-evidence li{border:1px solid var(--border,#d8e0da);border-radius:8px;padding:14px}.verification-evidence li>div{flex-wrap:wrap;justify-content:space-between;gap:8px 16px;display:flex}.verification-evidence li span{font-size:.875em}.verification-evidence li p{overflow-wrap:anywhere;margin:8px 0 0}.verification-evidence li[data-state=complete]{border-left:4px solid #377457}.verification-evidence li[data-state=blocked]{border-left:4px solid #a64a25}@media (width<=600px){.integration-verification{padding:16px}.integration-verification>.surface-head{flex-direction:column;align-items:stretch;gap:16px}.verification-start{grid-template-columns:minmax(0,1fr)}.verification-start button,.verification-bind button{width:100%}.verification-evidence li{padding:12px}}
|
|
2
|
+
/*$vite$:1*/
|
|
@@ -137,3 +137,6 @@ body.compact .form-actions button { flex: 1; }
|
|
|
137
137
|
.reference-card strong { width: calc(100% - 80px); }
|
|
138
138
|
}
|
|
139
139
|
@media (prefers-reduced-motion: reduce) { *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } }
|
|
140
|
+
|
|
141
|
+
.participant-case-link { display: inline-flex; min-height: 44px; align-items: center; padding: 8px 16px; border: 1px solid currentColor; border-radius: 10px; color: inherit; font-weight: 700; text-underline-offset: 3px; }
|
|
142
|
+
.participant-case-link:focus-visible { outline: 3px solid currentColor; outline-offset: 3px; }
|
|
@@ -364,6 +364,11 @@ async function submitReport() {
|
|
|
364
364
|
elements.receipt_copy.textContent = state.mode === "anonymous"
|
|
365
365
|
? `We’ll send updates to ${elements.reporter_email.value.trim().toLowerCase()}. You can close this window now.`
|
|
366
366
|
: "You can close this window now. Updates will be sent through the application’s notification channel.";
|
|
367
|
+
try {
|
|
368
|
+
const url = new URL(result.participant_url);
|
|
369
|
+
if (url.origin === location.origin && url.pathname === "/participant" && !url.search && /^#access=[a-f0-9]{64}$/.test(url.hash)) elements.participant_case.href = url.href;
|
|
370
|
+
} catch { /* A replay can recover access through the configured participant channel. */ }
|
|
371
|
+
if (!result.participant_url) elements.participant_case.textContent = "Get a private case link";
|
|
367
372
|
elements.success_view.focus();
|
|
368
373
|
post({ type: "safest-resolve:submitted", reference: result.reference || "", mode: state.mode });
|
|
369
374
|
} catch (error) {
|
|
@@ -404,5 +409,18 @@ globalThis.addEventListener("message", (event) => {
|
|
|
404
409
|
if (message.type === "safest-resolve:context-error") showFormError(typeof message.message === "string" ? message.message : "Secure reporting context was unavailable.");
|
|
405
410
|
});
|
|
406
411
|
|
|
407
|
-
|
|
412
|
+
// Measure outside ResizeObserver delivery: the parent updates this frame's
|
|
413
|
+
// height in response, and WebKit can otherwise report a resize feedback loop.
|
|
414
|
+
let resizeFrame = 0;
|
|
415
|
+
let reportedHeight = -1;
|
|
416
|
+
new ResizeObserver(() => {
|
|
417
|
+
if (resizeFrame) return;
|
|
418
|
+
resizeFrame = requestAnimationFrame(() => {
|
|
419
|
+
resizeFrame = 0;
|
|
420
|
+
const height = document.documentElement.scrollHeight;
|
|
421
|
+
if (height === reportedHeight) return;
|
|
422
|
+
reportedHeight = height;
|
|
423
|
+
post({ type: "safest-resolve:resize", height });
|
|
424
|
+
});
|
|
425
|
+
}).observe(elements.report_card);
|
|
408
426
|
void loadConfig();
|
|
@@ -84,6 +84,8 @@
|
|
|
84
84
|
<p id="receipt-copy">You can close this window now. Updates will be sent through the application’s notification channel.</p>
|
|
85
85
|
<div class="reference-card"><span>Your reference</span><strong id="receipt-reference"></strong><button id="copy-reference" type="button">Copy</button></div>
|
|
86
86
|
<p class="notification-reminder"><span aria-hidden="true">●</span> Keep the reference in case the safety team asks for it.</p>
|
|
87
|
+
<p><a id="participant-case" class="participant-case-link" href="/participant" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Open your case</a></p>
|
|
88
|
+
<p class="notification-reminder">Your private case link lets you send more information or request an independent review. Keep it private.</p>
|
|
87
89
|
<button id="finish-report" class="primary finish-button" type="button">Done</button>
|
|
88
90
|
</section>
|
|
89
91
|
</main>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
|
+
<meta name="safest-release" content="a5f252eca115ed4eac3c" />
|
|
4
5
|
<meta charset="UTF-8" />
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
7
|
<meta name="description" content="Review reports, record decisions, send updates, and follow application actions and appeals on Cloudflare." />
|
|
@@ -9,7 +10,9 @@
|
|
|
9
10
|
<title>Safest Resolve — Reports and workflows</title>
|
|
10
11
|
<link rel="icon" href="./favicon.svg" type="image/svg+xml" />
|
|
11
12
|
<link rel="manifest" href="./manifest.webmanifest" />
|
|
12
|
-
<link rel="stylesheet" href="./styles.css" />
|
|
13
|
+
<link rel="stylesheet" href="./styles.css?v=b18e3c241c3d5c5b9f42" />
|
|
14
|
+
<link rel="stylesheet" href="./workspace.css?v=2b40869062cdbab843d4" />
|
|
15
|
+
<link rel="stylesheet" href="./console/workspace-features.css?v=a8eb659dcde4ef39f5a9" />
|
|
13
16
|
</head>
|
|
14
17
|
<body class="auth-resolving">
|
|
15
18
|
<svg class="svg-defs" aria-hidden="true">
|
|
@@ -73,7 +76,7 @@
|
|
|
73
76
|
|
|
74
77
|
<div class="content">
|
|
75
78
|
<section id="command" class="command-view" data-view="command">
|
|
76
|
-
<div id="command-centre-root"><div class="skeleton-block" role="status" aria-label="Loading
|
|
79
|
+
<div id="command-centre-root"><div class="skeleton-block" role="status" aria-label="Loading Today"></div></div>
|
|
77
80
|
</section>
|
|
78
81
|
|
|
79
82
|
<section id="reports" class="queue-view" data-view="reports">
|
|
@@ -96,7 +99,7 @@
|
|
|
96
99
|
|
|
97
100
|
<section id="profile" class="view-stack" data-view="profile" hidden><div id="profile-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading profile"></div></div></section>
|
|
98
101
|
|
|
99
|
-
<section id="analytics" class="view-stack" data-view="analytics" hidden><div id="analytics-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading
|
|
102
|
+
<section id="analytics" class="view-stack" data-view="analytics" hidden><div id="analytics-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading Insights"></div></div></section>
|
|
100
103
|
|
|
101
104
|
<section id="appeals" class="view-stack" data-view="appeals" hidden><div id="appeal-workspace-root"><div class="skeleton-block" role="status" aria-label="Loading appeals"></div></div></section>
|
|
102
105
|
|
|
@@ -124,8 +127,8 @@
|
|
|
124
127
|
<div id="quality-dialog-root"></div>
|
|
125
128
|
|
|
126
129
|
<div id="operations-dialog-root"></div>
|
|
127
|
-
<script src="./customer-brand.js"></script>
|
|
128
|
-
<script src="./widget.js"></script>
|
|
129
|
-
<script src="./console/auth-shell.js" type="module"></script>
|
|
130
|
+
<script src="./customer-brand.js?v=fd02f0d24c8c8fd84cb7"></script>
|
|
131
|
+
<script src="./widget.js?v=7c87ca444a26368f65ec"></script>
|
|
132
|
+
<script src="./console/auth-shell.js?v=112db577467f923be438" type="module"></script>
|
|
130
133
|
</body>
|
|
131
134
|
</html>
|
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
:root{font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#17251f;background:#f5f7f5;font-size:16px;line-height:1.55;color-scheme:light}*{box-sizing:border-box}[hidden]{display:none!important}body{margin:0}.site-header{display:flex;justify-content:space-between;align-items:center;gap:16px;padding:20px max(20px,env(safe-area-inset-left));border-bottom:1px solid #d6dfd8;background:white}.brand{font-weight:700}main{max-width:760px;margin:28px auto;padding:0 20px}section{background:#fff;border:1px solid #d6dfd8;border-radius:16px;padding:28px;margin-bottom:20px}h1{font-size:clamp(1.5rem,5vw,2rem);line-height:1.2;margin:8px 0 16px}h2{font-size:1.25rem;line-height:1.3;margin:0 0 12px}p{margin:12px 0}.eyebrow,.muted,time{color:#526259}.eyebrow{font-size:.85rem;overflow-wrap:anywhere}label{display:block;margin:18px 0 6px;font-weight:600}input,textarea,button{font:inherit}input,textarea{display:block;width:100%;border:1px solid #899a8e;background:white;color:#17251f;border-radius:8px;padding:12px}textarea{resize:vertical;min-height:140px}button{border:1px solid #174e38;background:#174e38;color:white;border-radius:8px;padding:12px 18px;min-height:44px;cursor:pointer;font-weight:650}form button{margin-top:16px}.secondary{background:white;color:#174e38}button:disabled{opacity:.6;cursor:wait}:focus-visible{outline:3px solid #357cd0;outline-offset:3px}.status{display:inline-block;border-radius:20px;background:#e5efe8;color:#174e38;padding:5px 12px;font-weight:600}.message,.appeal{border-left:3px solid #bccdc1;padding:4px 16px;margin:20px 0}.message.inbound{border-left-color:#327757}.message p,.appeal p{white-space:pre-wrap;overflow-wrap:anywhere}.message strong{display:block}time{font-size:.85rem}.notice,#feedback{border-radius:8px;background:#e9f0ec;padding:14px 16px}#feedback:empty{display:none}#feedback.error{background:#fff0e7;color:#792b10}footer{max-width:760px;margin:24px auto;padding:0 20px 32px;color:#526259;font-size:.875rem}@media(max-width:540px){.site-header{padding:14px 16px;align-items:flex-start}.site-header button{font-size:.8rem;padding:8px 10px;max-width:150px}main{margin-top:18px;padding:0 12px}section{padding:20px 16px;border-radius:12px}form button{width:100%}}
|
|
2
|
+
.section-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.section-actions h2{margin:0}.section-actions button{font-size:.9rem}#older-messages{margin-bottom:8px}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"><meta name="referrer" content="no-referrer"><title>Your safety case</title><link rel="stylesheet" href="/participant.css"><script src="/participant.js" defer></script></head>
|
|
3
|
+
<body><header class="site-header"><span class="brand">Your safety case</span><button id="sign-out" class="secondary" hidden>Close private session</button></header>
|
|
4
|
+
<main><p id="feedback" role="status" aria-live="polite">Opening your private case…</p>
|
|
5
|
+
<section id="access" hidden aria-labelledby="access-title"><h1 id="access-title">Return to your case</h1><p>Your case link is private. A report reference by itself cannot open a case.</p>
|
|
6
|
+
<h2>Request a new private link</h2><p>Use the reference and email you originally gave the safety team.</p>
|
|
7
|
+
<form id="recovery-form"><label for="reference">Report reference</label><input id="reference" name="reference" required maxlength="100" autocomplete="off" placeholder="RPT-…"><label for="email">Email on the case</label><input id="email" name="email" type="email" required maxlength="254" autocomplete="email"><button type="submit">Send a new link</button></form>
|
|
8
|
+
<p class="muted">For a report linked to your account with the service, return through that signed-in account to request a fresh case link. If you reported anonymously without an email, we cannot verify ownership using your reference alone. Use a still-open private session or contact the service where you reported the issue. Do not post your private link publicly.</p></section>
|
|
9
|
+
<div id="case" hidden><section aria-labelledby="case-title"><p class="eyebrow" id="case-reference"></p><h1 id="case-title">Your conversation with the safety team</h1><p><span id="case-status" class="status"></span></p><p id="audience-description" class="muted"></p><p id="anonymous-warning" class="notice" hidden>This browser session lasts up to seven days. There is no email on this case for access recovery. Keep this session open; closing it or clearing your browser data can permanently remove your access.</p></section>
|
|
10
|
+
<section aria-labelledby="messages-title"><div class="section-actions"><h2 id="messages-title">Conversation</h2><button id="refresh-case" class="secondary">Check for updates</button></div><button id="older-messages" class="secondary" hidden>Load older messages</button><div id="messages"></div></section>
|
|
11
|
+
<section aria-labelledby="reply-title"><h2 id="reply-title">Add information or ask a question</h2><p>This goes to the safety team. Asking a question does not submit an appeal.</p><form id="reply-form"><label for="reply">Your message</label><textarea id="reply" name="reply" rows="5" required minlength="3" maxlength="8000"></textarea><p id="reply-pending" class="notice" hidden>We could not confirm whether your message arrived. Retry the same message to check safely.</p><button type="submit">Send to safety team</button></form></section>
|
|
12
|
+
<section id="appeal-section" aria-labelledby="appeal-title"><h2 id="appeal-title">Request an independent appeal</h2><div id="appeals"></div><p id="appeal-unavailable" class="muted"></p><form id="appeal-form" hidden><p>Explain why you disagree with the decision and what should be reconsidered. A different reviewer must review your appeal. You can submit one appeal per decision.</p><label for="appeal-reason">Why are you appealing?</label><textarea id="appeal-reason" name="reason" rows="5" required minlength="3" maxlength="8000"></textarea><p id="appeal-pending" class="notice" hidden>We could not confirm whether your appeal arrived. Retry the same request to check safely.</p><button type="submit">Submit appeal</button></form></section></div>
|
|
13
|
+
</main><footer>Only your conversation is shown here. The safety team keeps other participants’ information private. <a href="/participant#recover">Request a new access link</a></footer></body></html>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
'use strict';
|
|
3
|
+
const get = id => document.getElementById(id);
|
|
4
|
+
const token = new URLSearchParams(location.hash.slice(1)).get('access');
|
|
5
|
+
const recovering = location.hash === '#recover';
|
|
6
|
+
const invitationRequested = Boolean(location.hash) && !recovering;
|
|
7
|
+
// Remove the bearer before any application fetch, rendering, or user navigation.
|
|
8
|
+
if (location.hash) history.replaceState(null, '', location.pathname);
|
|
9
|
+
let csrf = ''; let current = null; let viewEpoch = 0;
|
|
10
|
+
const pending = { reply: null, appeal: null };
|
|
11
|
+
const retryAt = { reply: 0, appeal: 0 };
|
|
12
|
+
const messageHistory = new Map();
|
|
13
|
+
let olderCursor = null;
|
|
14
|
+
function feedback(message, error = false) { get('feedback').textContent = message; get('feedback').classList.toggle('error', error); }
|
|
15
|
+
async function api(path, options = {}) {
|
|
16
|
+
const response = await fetch(`/v1/participant/${path}`, { ...options, credentials: 'same-origin', cache: 'no-store', headers: { 'content-type': 'application/json', 'x-csrf-token': csrf, ...options.headers } });
|
|
17
|
+
const data = await response.json();
|
|
18
|
+
if (!response.ok) { const error = new Error(data.error?.message || 'The request could not be completed. Your text is still here.'); error.status = response.status; error.code = data.error?.code; error.retryAfterSeconds = data.error?.details?.retryAfterSeconds; throw error; }
|
|
19
|
+
return data;
|
|
20
|
+
}
|
|
21
|
+
function draftKey(kind) { return `safest-participant:${current?.reference}:${current?.audience}:${kind}`; }
|
|
22
|
+
function saveDraft(kind) { if (!current) return; try { sessionStorage.setItem(draftKey(kind), JSON.stringify({ text: get(kind === 'reply' ? 'reply' : 'appeal-reason').value, pending: pending[kind], retryAt: retryAt[kind] })); } catch {} }
|
|
23
|
+
function restoreDraft(kind) { pending[kind] = null; retryAt[kind] = 0; get(kind === 'reply' ? 'reply' : 'appeal-reason').value = ''; try { const saved = JSON.parse(sessionStorage.getItem(draftKey(kind)) || 'null'); if (saved) { get(kind === 'reply' ? 'reply' : 'appeal-reason').value = saved.text || ''; pending[kind] = saved.pending || null; retryAt[kind] = Number(saved.retryAt) || 0; } } catch {} updatePending(kind); }
|
|
24
|
+
function updatePending(kind) { get(`${kind}-pending`).hidden = !pending[kind]; get(kind === 'reply' ? 'reply' : 'appeal-reason').readOnly = Boolean(pending[kind]); }
|
|
25
|
+
function paragraph(text) { const p = document.createElement('p'); p.textContent = text; return p; }
|
|
26
|
+
function date(value) { return new Date(value).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); }
|
|
27
|
+
function showAccess(message) {
|
|
28
|
+
viewEpoch += 1;
|
|
29
|
+
current = null; csrf = ''; olderCursor = null; messageHistory.clear();
|
|
30
|
+
get('messages').replaceChildren(); get('appeals').replaceChildren();
|
|
31
|
+
for (const id of ['case-reference', 'case-status', 'audience-description']) get(id).textContent = '';
|
|
32
|
+
for (const kind of ['reply', 'appeal']) { pending[kind] = null; retryAt[kind] = 0; get(kind === 'reply' ? 'reply' : 'appeal-reason').value = ''; updatePending(kind); updateRateWait(kind); }
|
|
33
|
+
get('access').hidden = false; get('case').hidden = true; get('sign-out').hidden = true; feedback(message, Boolean(message));
|
|
34
|
+
}
|
|
35
|
+
// Opening a different fragment on the same /participant document does not
|
|
36
|
+
// restart scripts. Clear the old case immediately and perform a fresh exchange.
|
|
37
|
+
addEventListener('hashchange', () => { if (location.hash) { showAccess('Opening your private case…'); location.reload(); } });
|
|
38
|
+
function renderMessages() {
|
|
39
|
+
const messages = [...messageHistory.values()].sort((left, right) => left.createdAt < right.createdAt ? -1 : left.createdAt > right.createdAt ? 1 : left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
|
|
40
|
+
get('messages').replaceChildren(...messages.map(message => { const article = document.createElement('article'); article.className = `message ${message.direction}`; article.dataset.messageId = message.id; const who = document.createElement('strong'); who.textContent = message.direction === 'inbound' ? 'You' : 'Safety team'; const time = document.createElement('time'); time.dateTime = message.createdAt; time.textContent = date(message.createdAt); article.append(who, time, paragraph(message.body)); return article; }));
|
|
41
|
+
if (!messages.length) get('messages').append(paragraph('Your case is with the safety team. Their updates and your replies will appear here.'));
|
|
42
|
+
get('older-messages').hidden = !olderCursor;
|
|
43
|
+
}
|
|
44
|
+
function renderCase(data) {
|
|
45
|
+
const first = !current || current.reference !== data.reference || current.audience !== data.audience;
|
|
46
|
+
current = data; get('access').hidden = true; get('case').hidden = false; get('sign-out').hidden = false;
|
|
47
|
+
get('case-reference').textContent = data.reference; get('case-status').textContent = data.status;
|
|
48
|
+
get('audience-description').textContent = data.audience === 'reporter' ? 'You reported this issue. Your messages are shared with the safety team.' : 'This report involves you. Your messages are shared with the safety team.';
|
|
49
|
+
get('anonymous-warning').hidden = data.recoveryAvailable;
|
|
50
|
+
get('anonymous-warning').textContent = data.customerReentryAvailable
|
|
51
|
+
? 'There is no email on this case. To return later, request a fresh case link through your signed-in account with the service where you reported the issue.'
|
|
52
|
+
: 'This browser session lasts up to seven days. There is no email on this case for access recovery. Keep this session open; closing it or clearing your browser data can permanently remove your access.';
|
|
53
|
+
if (first) messageHistory.clear();
|
|
54
|
+
// Preserve already loaded older messages while refreshing the latest page.
|
|
55
|
+
// With no overlap, restart paging at the newest page to fill the gap safely.
|
|
56
|
+
const overlaps = data.messages.some(message => messageHistory.has(message.id));
|
|
57
|
+
if (first || !overlaps) olderCursor = data.olderMessagesCursor;
|
|
58
|
+
for (const message of data.messages) messageHistory.set(message.id, message);
|
|
59
|
+
renderMessages();
|
|
60
|
+
const labels = { submitted: 'Appeal received', human_review: 'Under independent review', upheld: 'Decision upheld', modified: 'Decision modified', reversed: 'Decision reversed' };
|
|
61
|
+
get('appeals').replaceChildren(...data.appeals.map(appeal => { const article = document.createElement('article'); article.className = 'appeal'; const heading = document.createElement('strong'); heading.textContent = labels[appeal.state] || 'Appeal in progress'; article.append(heading, paragraph(appeal.reason)); if (appeal.completionState === 'waiting') article.append(paragraph('The appeal has been reviewed. The safety team is still completing the required follow-up.')); return article; }));
|
|
62
|
+
get('appeal-form').hidden = !data.canAppeal; get('appeal-unavailable').textContent = data.canAppeal ? '' : data.appeals.length ? 'Your appeal is recorded above. You can add information using the message form.' : 'You can request an appeal here after a decision has been recorded.';
|
|
63
|
+
if (first) { restoreDraft('reply'); restoreDraft('appeal'); }
|
|
64
|
+
}
|
|
65
|
+
async function refresh() { const epoch = viewEpoch; const result = await api('case'); if (epoch !== viewEpoch) return; csrf = result.csrfToken; renderCase(result.case); }
|
|
66
|
+
get('older-messages').addEventListener('click', async () => {
|
|
67
|
+
if (!olderCursor) return;
|
|
68
|
+
const button = get('older-messages'); button.disabled = true;
|
|
69
|
+
const reference = current?.reference; const audience = current?.audience;
|
|
70
|
+
const previousFirst = get('messages').firstElementChild;
|
|
71
|
+
const anchorId = previousFirst?.dataset.messageId;
|
|
72
|
+
const anchorTop = previousFirst?.getBoundingClientRect().top;
|
|
73
|
+
try {
|
|
74
|
+
const result = await api(`messages?before=${encodeURIComponent(olderCursor)}`);
|
|
75
|
+
if (current?.reference !== reference || current?.audience !== audience) return;
|
|
76
|
+
for (const message of result.messages) messageHistory.set(message.id, message);
|
|
77
|
+
olderCursor = result.olderMessagesCursor; renderMessages();
|
|
78
|
+
const anchor = [...get('messages').children].find(element => element.dataset.messageId === anchorId);
|
|
79
|
+
if (anchor && typeof anchorTop === 'number') scrollBy(0, anchor.getBoundingClientRect().top - anchorTop);
|
|
80
|
+
} catch (error) { feedback(error.message, true); } finally { button.disabled = false; }
|
|
81
|
+
});
|
|
82
|
+
get('refresh-case').addEventListener('click', async () => { const button = get('refresh-case'); button.disabled = true; try { await refresh(); feedback('Your conversation is up to date.'); } catch (error) { feedback(error.message, true); } finally { button.disabled = false; } });
|
|
83
|
+
function updateRateWait(kind) {
|
|
84
|
+
const button = get(`${kind}-form`).querySelector('button');
|
|
85
|
+
const remaining = Math.max(0, Math.ceil((retryAt[kind] - Date.now()) / 1000));
|
|
86
|
+
if (remaining) { button.disabled = true; button.dataset.rateWait = 'true'; button.textContent = `Try again in ${Math.floor(remaining / 60)}:${String(remaining % 60).padStart(2, '0')}`; }
|
|
87
|
+
else if (button.dataset.rateWait) { delete button.dataset.rateWait; button.disabled = false; button.textContent = kind === 'reply' ? 'Send to safety team' : 'Submit appeal'; }
|
|
88
|
+
}
|
|
89
|
+
setInterval(() => { for (const kind of ['reply', 'appeal']) updateRateWait(kind); }, 1000);
|
|
90
|
+
for (const kind of ['reply', 'appeal']) {
|
|
91
|
+
const field = get(kind === 'reply' ? 'reply' : 'appeal-reason'); field.addEventListener('input', () => saveDraft(kind));
|
|
92
|
+
get(`${kind}-form`).addEventListener('submit', async event => {
|
|
93
|
+
event.preventDefault(); if (retryAt[kind] > Date.now()) return; const button = event.currentTarget.querySelector('button'); button.disabled = true;
|
|
94
|
+
if (!pending[kind]) pending[kind] = { key: crypto.randomUUID(), body: kind === 'reply' ? { body: field.value.trim() } : { reason: field.value.trim(), decisionId: current.appealDecisionId } };
|
|
95
|
+
saveDraft(kind); updatePending(kind);
|
|
96
|
+
try {
|
|
97
|
+
await api(kind === 'reply' ? 'messages' : 'appeals', { method: 'POST', headers: { 'idempotency-key': pending[kind].key }, body: JSON.stringify(pending[kind].body) });
|
|
98
|
+
pending[kind] = null; field.value = ''; saveDraft(kind); updatePending(kind);
|
|
99
|
+
await refresh(); feedback(kind === 'reply' ? 'Your message was received by the safety team.' : 'Your appeal was received. An independent reviewer will review it.'); get('feedback').scrollIntoView({ block: 'nearest' });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error.status && error.status < 500 && error.status !== 408) { pending[kind] = null; saveDraft(kind); updatePending(kind); }
|
|
102
|
+
if (error.status === 409) { try { await refresh(); } catch {} }
|
|
103
|
+
if (error.status === 429 && Number.isFinite(error.retryAfterSeconds)) { retryAt[kind] = Date.now() + error.retryAfterSeconds * 1000; saveDraft(kind); updateRateWait(kind); }
|
|
104
|
+
feedback(error.message, true);
|
|
105
|
+
} finally { button.disabled = retryAt[kind] > Date.now(); }
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
get('recovery-form').addEventListener('submit', async event => { event.preventDefault(); const button = event.currentTarget.querySelector('button'); button.disabled = true; try { const result = await api('recovery', { method: 'POST', body: JSON.stringify({ reference: get('reference').value, email: get('email').value }) }); feedback(result.message); } catch (error) { feedback(error.message, true); } finally { button.disabled = false; } });
|
|
109
|
+
get('sign-out').addEventListener('click', async () => { if (!current?.recoveryAvailable && !current?.customerReentryAvailable && !confirm('There is no email for access recovery. Closing this session may permanently remove your access. Close it?')) return; try { await api('session', { method: 'DELETE' }); for (const kind of ['reply', 'appeal']) try { sessionStorage.removeItem(draftKey(kind)); } catch {} current = null; showAccess('Your private session is closed.'); } catch (error) { feedback(error.message, true); } });
|
|
110
|
+
(async () => { if (recovering) { showAccess('Request a new link using the email on your case.'); return; } try { if (invitationRequested) { const result = await api('exchange', { method: 'POST', body: JSON.stringify({ token: token || '' }) }); csrf = result.csrfToken; } await refresh(); feedback(''); } catch (error) { showAccess(invitationRequested ? error.message : 'Open the private link from your report receipt or safety team email, or request a new one below.'); } })();
|
|
111
|
+
})();
|
|
@@ -4,6 +4,8 @@ const formError = document.querySelector("#form-error");
|
|
|
4
4
|
const idempotencyKey = crypto.randomUUID();
|
|
5
5
|
let config;
|
|
6
6
|
let turnstileToken = "";
|
|
7
|
+
let turnstileId = null;
|
|
8
|
+
let submitting = false;
|
|
7
9
|
|
|
8
10
|
function option(value, label) {
|
|
9
11
|
const element = document.createElement("option");
|
|
@@ -120,6 +122,7 @@ try {
|
|
|
120
122
|
const result = await response.json().catch(() => ({}));
|
|
121
123
|
if (!response.ok) throw new Error(result?.error?.message || "Public reporting is unavailable.");
|
|
122
124
|
config = result;
|
|
125
|
+
if (config.reporter_notifications_available === false) throw new Error("Reporting is temporarily unavailable. Please contact the service owner or try again later.");
|
|
123
126
|
globalThis.SafestBrand?.applyBrand(config.brand, { title: config.form.title || "Report a safety concern" });
|
|
124
127
|
document.querySelector("#title").textContent = config.form.title;
|
|
125
128
|
document.querySelector("#description").textContent = config.form.description;
|
|
@@ -128,7 +131,7 @@ try {
|
|
|
128
131
|
document.querySelector("#reason").replaceChildren(...config.form.reasons.map((reason) => option(reason.code, reason.label)));
|
|
129
132
|
document.querySelector("#answer-fields").replaceChildren(...(config.form.fields || []).map(renderField));
|
|
130
133
|
const turnstile = await waitForTurnstile();
|
|
131
|
-
turnstile.render("#turnstile-widget", {
|
|
134
|
+
turnstileId = turnstile.render("#turnstile-widget", {
|
|
132
135
|
sitekey: config.turnstile_site_key,
|
|
133
136
|
action: "report_submit",
|
|
134
137
|
callback: (token) => { turnstileToken = token; },
|
|
@@ -142,6 +145,7 @@ try {
|
|
|
142
145
|
|
|
143
146
|
form.addEventListener("submit", async (event) => {
|
|
144
147
|
event.preventDefault();
|
|
148
|
+
if (submitting) return;
|
|
145
149
|
formError.textContent = "";
|
|
146
150
|
const button = form.querySelector("button");
|
|
147
151
|
if (!turnstileToken) {
|
|
@@ -154,6 +158,8 @@ form.addEventListener("submit", async (event) => {
|
|
|
154
158
|
const evidenceUrl = document.querySelector("#evidence-url").value.trim();
|
|
155
159
|
const timestamp = new Date().toISOString();
|
|
156
160
|
button.disabled = true;
|
|
161
|
+
submitting = true;
|
|
162
|
+
button.textContent = "Submitting…";
|
|
157
163
|
try {
|
|
158
164
|
const response = await fetch("/v1/reports/public", {
|
|
159
165
|
method: "POST",
|
|
@@ -176,9 +182,23 @@ form.addEventListener("submit", async (event) => {
|
|
|
176
182
|
form.hidden = true;
|
|
177
183
|
document.querySelector("#receipt-reference").textContent = `Keep reference ${result.reference} for your records.`;
|
|
178
184
|
document.querySelector("#receipt-notification").textContent = `Updates will be sent to ${document.querySelector("#reporter-email").value.trim().toLowerCase()}.`;
|
|
185
|
+
const caseLink = document.querySelector("#participant-case");
|
|
186
|
+
try {
|
|
187
|
+
const url = new URL(result.participant_url);
|
|
188
|
+
if (url.origin === location.origin && url.pathname === "/participant" && !url.search && /^#access=[a-f0-9]{64}$/.test(url.hash)) caseLink.href = url.href;
|
|
189
|
+
} catch { /* The access page offers email recovery when no new link was issued. */ }
|
|
190
|
+
if (!result.participant_url) caseLink.textContent = "Get a private case link";
|
|
179
191
|
document.querySelector("#receipt").hidden = false;
|
|
192
|
+
const receipt = document.querySelector("#receipt h2");
|
|
193
|
+
receipt.tabIndex = -1;
|
|
194
|
+
receipt.focus();
|
|
180
195
|
} catch (error) {
|
|
181
196
|
formError.textContent = error instanceof Error ? error.message : "The report could not be accepted.";
|
|
182
197
|
button.disabled = false;
|
|
198
|
+
button.textContent = "Submit report";
|
|
199
|
+
turnstileToken = "";
|
|
200
|
+
if (turnstileId !== null) globalThis.turnstile?.reset(turnstileId);
|
|
201
|
+
} finally {
|
|
202
|
+
submitting = false;
|
|
183
203
|
}
|
|
184
204
|
});
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
<button class="primary" type="submit">Submit report</button>
|
|
30
30
|
<p id="form-error" class="error" role="alert"></p>
|
|
31
31
|
</form>
|
|
32
|
-
<div id="receipt" class="receipt" hidden><h2>Report received</h2><p id="receipt-reference"></p><p id="receipt-notification"></p></div>
|
|
32
|
+
<div id="receipt" class="receipt" hidden><h2>Report received</h2><p id="receipt-reference"></p><p id="receipt-notification"></p><p><a id="participant-case" href="/participant" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">Open your case</a></p><p>Use your private case link to send more information or request an independent review. Keep it private.</p></div>
|
|
33
33
|
<p id="setup-error" class="error" role="alert"></p>
|
|
34
34
|
</article>
|
|
35
35
|
</main>
|