/** * - WHY THE SESSION ENDED, CARRIED TO THE LOGIN PAGE. * * A staff member is signed out and lands on `#/login` with nothing on the screen to say what happened. * They cannot tell "I was idle for an hour", "this browser lost the key its session was bound to" and * "somebody signed me out" apart, and every one of them reads as the product losing their work. * * THE IDLE PATH ALREADY DID THIS AND WAS THE ONLY ONE. It wrote a flag and the login page rendered a * sentence for it. Every other involuntary exit wrote nothing - the refresh token being gone, the device * key being unavailable, Cognito refusing the refresh outright. Those are the ones a person cannot * possibly guess. * * ONE KEY, NOT ONE FLAG PER CAUSE. A second boolean beside the first is how two alerts end up rendering * at once, each describing a different reason for the same sign-out. * * A DELIBERATE SIGN-OUT RECORDS NOTHING, and that is the control on this whole mechanism: somebody who * chose to sign out does not need to be told they signed out. `logout()` takes no reason by default. * * `sessionStorage`, not `localStorage`: this describes ONE event on ONE tab. In `localStorage` it would * greet a second tab that never lost anything. */ export const SESSION_END_REASON_KEY = 'akashic_sessionEndReason'; export type SessionEndReason = /** The idle timer fired. */ | 'inactivity' /** Cognito looked at the refresh token and refused it, or there was none to present. */ | 'refresh-rejected' /** The session was bound to a key this browser can no longer produce. */ | 'device-key-lost' /** Signed in, but the account is missing something the app needs to open a session with. */ | 'session-incomplete' /** * The account is complete and the read was made - it did not answer. Distinct from * `session-incomplete` on purpose: that one says the ACCOUNT is short of something, and saying it * here would blame the person for our own gateway being unreachable. */ | 'profile-unreadable' /** Refused their own profile, and the token no longer names this practice: removed from its staff. */ | 'access-ended' /** Just joined a practice, and their access there was not ready before the join wait ran out. */ | 'join-not-ready' | 'agreement-declined' /** Signed out here, but the browser could not record it for its other tabs. */ | 'signout-unconfirmed' /** A renewal may have rotated the token and its result could not be kept, so it is not spent again. */ | 'renewal-unknown' /** The browser would not keep the sign-in at all. */ | 'credential-storage'; const SENTENCES: Record = { // Unchanged wording, deliberately: this sentence was already on screen and is the one arm of this // mechanism that was already right. inactivity: 'You were signed out because the session was inactive for 60 minutes. Sign in again to pick up where you left off.', 'refresh-rejected': 'Your session ended, please log back in to continue', // The one nobody can guess. It is specific to THIS browser, which is the actionable part - the same // person on their phone is unaffected. 'device-key-lost': 'This browser could not prove it was the one your session started on, so the session was ended. Signing in again will set it up afresh.', 'session-incomplete': 'We could not finish opening your session. Sign in again, and tell us if it keeps happening.', // Says what failed rather than what is missing, because nothing of theirs is missing. Same closing // instruction as the one above: retrying is genuinely worth a try, and a repeat is ours to hear about. 'profile-unreadable': 'We could not load your account details, so the session could not be opened. Sign in again, and tell us if it keeps happening.', // Signing in again cannot help someone the practice has removed, so this does not suggest it. 'access-ended': "You were signed out because your account no longer has access to this practice. If you think that's a mistake, ask the practice's administrator.", // Their membership stands; only the access it gives was late. Signing in again a minute later is the remedy. 'join-not-ready': 'You have joined the practice, but your access there was not ready in time, so the session could not be opened. Your membership is kept: sign in again in a minute.', // A decline is the person's own choice, but it still ends the session, so sign-in says what happened. 'agreement-declined': 'You were signed out because you declined an Akashic agreement. It will be shown again the next time you sign in.', 'signout-unconfirmed': 'You are signed out here, but other tabs on this browser could not be told. Close them, or sign out there too.', 'renewal-unknown': 'Your session could not be renewed on this browser, so it was not used again. Sign in again to continue.', 'credential-storage': 'This browser would not keep your sign-in. Allow site storage for this page, reload, and sign in again.', }; /** Not a throw: a browser that refuses storage must still be able to sign somebody out. */ export function recordSessionEnd(reason: SessionEndReason): void { try { sessionStorage.setItem(SESSION_END_REASON_KEY, reason); } catch { // A missing explanation is worse than none only if it costs the sign-out, which it must not. } } /** * Read the reason WITHOUT consuming it. Pair every call with `clearSessionEndReason` once a person has * actually had the chance to read it. * * WHY THIS IS NO LONGER READ-AND-CLEAR IN ONE STEP, and it was caught live rather than reasoned about. * The idle timeout signs a tab out after an hour, and it does so whether or not anybody is looking. The * captured sequence: `recordSessionEnd` writes the reason, `logout()` clears the session, the login page * mounts and consumes the reason - all within the same second, on a tab nobody was watching. The person * who came back an hour later got a bare sign-in screen, because the sentence had already been spent on a * render with no viewer. * -> Reading and clearing together answers "has a component read this?" when the question that matters is * "has a HUMAN seen this?". */ export function peekSessionEndReason(): SessionEndReason | undefined { try { const stored = sessionStorage.getItem(SESSION_END_REASON_KEY); return stored && stored in SENTENCES ? (stored as SessionEndReason) : undefined; } catch { return undefined; } } /** * Forget the reason, once it has been seen. * * IT MUST STILL BE ONE-SHOT. The value describes ONE event, and a reason that survived a later deliberate * sign-in would tell somebody they had been timed out when they had simply signed in again - which is a * worse message than none, because it is confidently wrong. */ export function clearSessionEndReason(): void { try { sessionStorage.removeItem(SESSION_END_REASON_KEY); } catch { // Never let tidying up the explanation break the screen that shows it. } } // In memory on purpose: the reload clears it. logout() turns the session off before reloading, so the // login page renders in place first and must not spend the reason. let reloadPending = false; export function holdSessionEndReasonUntilReload(): void { reloadPending = true; } /** * Forget the reason once the tab is actually in front of somebody, and not before. * * THE BRANCH LIVES HERE RATHER THAN IN THE PAGE SO IT CAN BE EXERCISED. Written inline in a component * effect, the only thing a test can check is that the words `visibilitychange` and * `clearSessionEndReason` appear in the file - and a mutation that clears on mount while leaving both * strings in place passes that check while restoring the exact defect. Two such mutants survived before * this was extracted. * * Returns its own cleanup, so a caller unmounting mid-wait removes the listener. */ export function clearSessionEndReasonWhenSeen(): () => void { const noop = () => undefined; // The login page that renders in place before a sign-out's reload is not the one anybody reads. if (reloadPending) return noop; if (typeof document === 'undefined') { // No document to ask - clearing immediately is the safe direction: the alternative is a reason that // never expires. clearSessionEndReason(); return noop; } if (document.visibilityState === 'visible') { clearSessionEndReason(); return noop; } const onVisible = () => { if (document.visibilityState === 'visible') clearSessionEndReason(); }; document.addEventListener('visibilitychange', onVisible); return () => document.removeEventListener('visibilitychange', onVisible); } /** * @deprecated Reads and clears in one step, which spends the sentence on whichever render happens first - * including one nobody is looking at. Use `peekSessionEndReason` + `clearSessionEndReasonWhenSeen`. */ export function takeSessionEndReason(): SessionEndReason | undefined { const reason = peekSessionEndReason(); if (reason) clearSessionEndReason(); return reason; } /** * Returns `undefined` for anything unrecognised rather than a generic sentence. A value this build * does not know about is a value written by a build that is not this one, and inventing an explanation * for it would put words on screen that no code here chose. */ export function sessionEndSentence(reason: SessionEndReason | undefined): string | undefined { return reason ? SENTENCES[reason] : undefined; }