import { useCallback, useEffect, useState } from 'react'; import { ShieldAlert } from 'lucide-react'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'; import { Button } from '../ui/button'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '../ui/alert-dialog'; import { useProofingServiceContext } from '../../services/ProofingService.tsx'; import { useProofingReturn } from '../../hooks/useProofingReturn.tsx'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; // Phase 2 (full-proxy identity-proofing hard-gate), client Task 7 - the related-person // verify-now surface. Mounted on both related-person-facing surfaces: the "helping {Patient}" // acting view (DashboardPage's global shell) and the related person's own ProfilePage // self-view. // // GATING (resolved by grep - see task-7-p2-report.md): there is no client-readable signal for // "this related person holds a full-proxy grant." RelatedPersonAuthorityService's // GET/PUT /related-person-authority is self-scoped to the caller-PATIENT only (server route: // allowedRoles: [PATIENTS] - infra routes.ts), not something a related person can read about // themselves. So this gates on proofingStatus !== 'verified' alone, with copy that stays honest // about the condition rather than asserting a tier we can't actually see. A follow-up to gate // strictly on the grant (once a self-read of the caller's own tier exists) is a small fast-follow, // not this task. /** One key, named for what it means: the PROMPT is dismissed, not the verification. */ const PROOFING_PROMPT_DISMISSED = 'akashic.proofing.promptDismissed'; const ProofingBanner = () => { const { getProofingStatus, startVerification } = useProofingServiceContext(); // 'loading' is a local sentinel (not a ProofingStatusValue) so we render nothing during the // initial fetch instead of flashing the prompt before we know the real status. const [status, setStatus] = useState<'loading' | 'pending' | 'verified' | 'failed' | 'expired' | 'none'>('loading'); const [starting, setStarting] = useState(false); const [error, setError] = useState(null); /** * THE WAY OUT OF A PROMPT THAT OTHERWISE HAS NONE. * * Spencer, 2026-09-10: *"Identity verification should have an escape hatch if they don't want * to start it again. So should have a Cancel button there and a confirmation should display saying * what the consequence of not finishing the verification is when they click it."* * * DISMISSING IS NOT DECLINING, and the difference is the whole reason for the confirmation. * Nothing here writes to the server: there is no cancel route, the stored Stripe session stays * `pending`, and the caregiver's grant is exactly what it was a second earlier. What changes is * that this card stops asking. The copy must not imply anything was given up, and the door must * stay open - which is why the profile page keeps the same prompt. * * Per browser, per person, and deliberately not per session: a banner that comes back on the * next page load is not an escape hatch, it is a delay. `localStorage` can throw in a private * window, so every access is guarded and a failure simply leaves the banner showing - the safe * direction, since the prompt is the thing that protects access. */ const [dismissed, setDismissed] = useState(() => { try { return localStorage.getItem(PROOFING_PROMPT_DISMISSED) === '1'; } catch { return false; } }); const [confirmingDismiss, setConfirmingDismiss] = useState(false); const dismiss = () => { try { localStorage.setItem(PROOFING_PROMPT_DISMISSED, '1'); } catch { // A browser that refuses storage still gets the dismissal for this render; it returns on // the next load. Better than refusing the gesture outright. } setConfirmingDismiss(false); setDismissed(true); }; const refresh = useCallback(() => { let cancelled = false; (async () => { try { const { status: fetched } = await getProofingStatus(); if (!cancelled) setStatus(fetched); } catch (err) { // Fail toward SHOWING the prompt rather than silently hiding it - a transient // status-fetch failure shouldn't leave a not-yet-verified caregiver believing // their access is active when it isn't. console.error('Failed to load proofing status:', err); if (!cancelled) setStatus('none'); } })(); return () => { cancelled = true; }; }, [getProofingStatus]); useEffect(() => refresh(), [refresh]); // Refresh status on return from Stripe Identity (Task 6's hook detects the return marker // itself and no-ops otherwise). useProofingReturn(useCallback(() => setStatus('verified'), [])); const handleVerify = async () => { setStarting(true); setError(null); try { await startVerification(); // redirects the browser on success - no further state change needed } catch (err) { console.error('Failed to start identity verification:', err); setError(relayServerFailure(err, 'Failed to start verification. Please try again.')); setStarting(false); } }; if (status === 'loading' || status === 'verified' || dismissed) { return null; } if (status === 'pending') { return ( Identity verification {/* * đź”» 2026-09-08 - THIS STATE WAS A DEAD END, and that is why it "just always * loads". Spencer: "Why is verifying just always loading now? I've noticed that * happening now for several days." * * `pending` is not a request in flight - it is the stored status of a Stripe * Identity session that was started and never came back. Two things make it * permanent. Nothing on the server ever writes `expired`: the record type * reserves `expiresAt` and says in its own comment that it is "unused in v1 (no * expiry)", so only a webhook can move a record off `pending`. And this branch * offered no action at all, so somebody who closed the Stripe tab had no way * back - the screen span forever and the button that starts a session was on * the OTHER branch, unreachable. * * The spinner goes. A spinner is a promise that something is happening, and * after the tab is closed nothing is. The words say what is actually true and * the door is open again. */}

You started verifying your identity and it hasn't finished. If you closed the window before the end, start again.

{/* `btn btn-green`, not {/* ⚖️ A SECOND CONTROL HERE IS NOT A CANCEL IN A MODAL - this is a card on a page, and the rule against a Cancel button governs dialogs. Without it the only way past this card was to finish a verification the person may not want to do, on a prompt that never expires. */} {error &&

{error}

} Stop asking about verification? {/* WHAT IT COSTS, IN THE ONLY TERMS THAT MATTER TO THE READER, and no more than is true. Verification is what turns a full-proxy caregiver grant on; until it is done that access stays off, and that is the whole of the consequence. Nothing is withdrawn and nothing expires, so the copy says neither. */} Until you finish verifying who you are, you can help with scheduling and forms but cannot see or act on the medical record of anyone who has given you full caregiver access. Nothing else changes, and nobody loses anything they have already given you.

You can start verification any time from your profile.
Keep verifying Stop asking
); } // failed / expired / none return ( Identity verification

If you've been granted full-proxy caregiver access, verify your identity to activate it.

{error &&

{error}

}
); }; export default ProofingBanner;