import React, { useEffect, useState } from 'react'; import { Lock, TriangleAlert } from 'lucide-react'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; import { Alert, AlertDescription } from '../ui/alert'; import { usePractitionerServiceContext } from '../../services'; import type { StaffPersonalProfileInput } from '../../services/PractitionerService'; import { isRetryableFailure } from '../../utils/fetchFailureMessage'; // --------------------------------------------------------------------------------------------- // - "Personal details (private)". The ONE surface where a staff member supplies their own // personal mobile and personal email. // --------------------------------------------------------------------------------------------- // SELF ONLY. This must never render on the admin staff-edit page, and its parent enforces that with // `isSelf`. The service calls behind it carry no id at all - the server resolves the subject from the // caller's own token - so even a mis-mount could not show one person another's details. But the mount // guard still matters: an admin seeing the FIELDS AT ALL, even empty, teaches them these values are // theirs to collect. // // WHY IT LIVES HERE AND NOT BEHIND A LINK TO THE PATIENT PORTAL. Spencer's first instinct was to send // staff to their patient profile. Tracing it found that arriving there stamps the employing org's // affiliation onto the org-less home Patient, which clears the demographics gate - and that projection // includes telecom and address. So the "go fill it in over there" route is what would have leaked the // value. akashic-docs/_CLAUDE/2026-08-11-card-230-staff-self-service-profile-design.md // // THE COPY MAY NOT PROMISE THE ORGANISATION CAN NEVER SEE IT. Already corrected once on `NewUserPage`: // the day this person is also a PATIENT of this practice, the org has a real care relationship and may // read the record legitimately. The honest claim is "not part of your staff record", never "invisible to // your employer". A test forbids the overclaim returning. // --------------------------------------------------------------------------------------------- export interface StaffPersonalProfileHandle { /** Values to submit, or `undefined` when nothing was edited. The parent saves; this owns the fields. */ getPendingInput: () => StaffPersonalProfileInput | undefined; } interface StaffPersonalProfileProps { disabled?: boolean; /** Called on every edit so the parent knows there is something to save. */ onChange: (input: StaffPersonalProfileInput | undefined) => void; } const StaffPersonalProfile: React.FC = ({ disabled = false, onChange }) => { const { fetchOwnPersonalProfile } = usePractitionerServiceContext(); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(false); // - a permanent denial and a transient failure are different events and must read // differently. Kept apart from `loadError` so the SECTION still renders either way. const [loadDenied, setLoadDenied] = useState(false); const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [initial, setInitial] = useState<{ phone: string; email: string }>({ phone: '', email: '' }); useEffect(() => { let cancelled = false; fetchOwnPersonalProfile() .then((profile) => { if (cancelled) return; const next = { phone: profile.personalPhone ?? '', email: profile.personalEmail ?? '' }; setInitial(next); setPhone(next.phone); setEmail(next.email); }) .catch((error) => { // A FAILED READ IS NOT AN EMPTY PROFILE. Rendering blank fields would invite the person // to retype details they already have - and a save from that state would overwrite good // values with whatever the blank form held. // console.error('Failed to load personal profile:', error); // - WHICH failure it was decides what we may honestly say. A `Staff`-group // member got a 403 here on the screen their workspace lands on, and the copy told them // to "try again in a moment" - advice for a blip, given for a permanent denial. That // wastes their time and hides the cause the same classification built for the // 41 hooks that made this mistake at scale. if (!cancelled) { setLoadError(true); setLoadDenied(!isRetryableFailure(error)); } }) .finally(() => { if (!cancelled) setLoading(false); }); return () => { cancelled = true; }; }, [fetchOwnPersonalProfile]); // Only CHANGED fields are submitted. Sending an untouched value would rewrite the record on every // save, and sending an empty untouched field would clear a number nobody meant to clear. const emit = (nextPhone: string, nextEmail: string) => { const input: StaffPersonalProfileInput = {}; if (nextPhone !== initial.phone) input.personalPhone = nextPhone; if (nextEmail !== initial.email) input.personalEmail = nextEmail; onChange(Object.keys(input).length > 0 ? input : undefined); }; if (loading) { return null; } if (loadError) { return ( {/* ⛔ NOT SUPPRESSED on a denial — a section that silently disappears tells the reader nothing and is the reference-authz-denial-never-renders-as-empty-data class. It says which of the two happened, and only one of them invites a retry. */} {loadDenied ? 'This account does not have access to personal details. Nothing has been changed — ask an administrator if you think it should.' : 'Could not load your personal details. Nothing has changed. Try again in a moment.'} ); } return (

These details belong to your personal profile, not your staff record, and are never shown to patients. They keep your records linked across practices.

{ setPhone(event.target.value); emit(event.target.value, email); }} />
{ setEmail(event.target.value); emit(phone, event.target.value); }} />

Use a personal email address you keep between jobs, rather than your work sign-in, so your records stay linked when you change practices.

); }; export default StaffPersonalProfile;