taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit ba68b867d072398f363d1ddc628dc947bd84371b
parent f0e82540712049d0404f35a6d19ec3c27b4a0668
Author: Florian Dold <dold@taler.net>
Date:   Thu, 27 Aug 2026 00:59:51 +0200

AML web UI: scope sessions and drafts to the active account

Diffstat:
Mpackages/taler-exchange-aml-webui/src/App.tsx | 11+++++++----
Mpackages/taler-exchange-aml-webui/src/Routing.tsx | 161+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-exchange-aml-webui/src/hooks/decision-request.ts | 265++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mpackages/taler-exchange-aml-webui/src/hooks/officer.ts | 323++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
4 files changed, 556 insertions(+), 204 deletions(-)

diff --git a/packages/taler-exchange-aml-webui/src/App.tsx b/packages/taler-exchange-aml-webui/src/App.tsx @@ -39,6 +39,7 @@ import { } from "./context/ui-settings.js"; import { revalidateAccountInformation } from "./hooks/account.js"; import { revalidateAccountDecisions } from "./hooks/decisions.js"; +import { OfficerProvider } from "./hooks/officer.js"; import { usePreferences } from "./hooks/preferences.js"; import { strings } from "./i18n/strings.js"; import "./scss/main.scss"; @@ -47,12 +48,12 @@ const WITH_LOCAL_STORAGE_CACHE = false; export function App(): VNode { const [settings, setSettings] = useState<UiSettings>(); + const [{ preventCompression }] = usePreferences(); useEffect(() => { fetchUiSettings(setSettings); }, []); if (!settings) return <Loading />; - const [{ preventCompression }] = usePreferences(); const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); return ( <UiSettingsProvider value={settings}> @@ -94,9 +95,11 @@ export function App(): VNode { }} > <BrowserHashNavigationProvider> - <UiFormsProvider> - <Routing /> - </UiFormsProvider> + <OfficerProvider> + <UiFormsProvider> + <Routing /> + </UiFormsProvider> + </OfficerProvider> </BrowserHashNavigationProvider> </SWRConfig> </ExchangeApiProvider> diff --git a/packages/taler-exchange-aml-webui/src/Routing.tsx b/packages/taler-exchange-aml-webui/src/Routing.tsx @@ -16,6 +16,9 @@ import { decodeCrockFromURI, + ErrorLoading, + FailLoading, + Loading, urlPattern, useCurrentLocation, useNavigationContext, @@ -23,16 +26,23 @@ import { } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; -import { assertUnreachable } from "@gnu-taler/taler-util"; +import { + assertUnreachable, + encodeCrock, + HttpStatusCode, + Paytos, + TalerError, +} from "@gnu-taler/taler-util"; import { useEffect } from "preact/hooks"; import { HandleSessionNotReady } from "./components/HandleAccountNotReady.js"; import { ExchangeAmlFrame } from "./ExchangeAmlFrame.js"; -import { useCurrentDecisionRequest } from "./hooks/decision-request.js"; import { - OfficerReady, - useExpireSessionAfter1hr, - useOfficer, -} from "./hooks/officer.js"; + clearDecisionDraft, + DecisionRequestProvider, + readDecisionDraftTarget, +} from "./hooks/decision-request.js"; +import { useAccountActiveDecision } from "./hooks/decisions.js"; +import { OfficerReady, useOfficer } from "./hooks/officer.js"; import { AccountDetails } from "./pages/AccountDetails.js"; import { AccountList, @@ -151,14 +161,11 @@ const privatePages = { function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { const { navigateTo } = useNavigationContext(); const location = useCurrentLocation(privatePages); - const [, , startNewRequest] = useCurrentDecisionRequest(); useEffect(() => { if (location.name === undefined) { navigateTo(privatePages.dashboard.url({})); } }, [location]); - useExpireSessionAfter1hr(); - switch (location.name) { case undefined: { return <Fragment />; @@ -168,7 +175,7 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { } case "decide": { return ( - <DecisionWizard + <DecisionPage officer={officer} account={location.values.cid} formId={ @@ -197,7 +204,7 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { } case "decideWithStep": { return ( - <DecisionWizard + <DecisionPage officer={officer} account={location.values.cid} formId={ @@ -227,7 +234,7 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { } case "decideNew": { return ( - <DecisionWizard + <DecisionPage officer={officer} account={location.values.cid} newPayto={decodeCrockFromURI(location.values.payto)} @@ -258,7 +265,7 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { } case "decideNewWithStep": { return ( - <DecisionWizard + <DecisionPage account={location.values.cid} officer={officer} newPayto={decodeCrockFromURI(location.values.payto)} @@ -294,8 +301,8 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { account={location.values.cid} routeToShowTransfers={privatePages.transfersForAccount} routeToShowCollectedInfo={privatePages.showCollectedInfo} - onNewDecision={(r) => { - startNewRequest(r); + onNewDecision={() => { + clearDecisionDraft(officer.session.id, location.values.cid); navigateTo( privatePages.decide.url({ cid: location.values.cid, @@ -313,7 +320,7 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { <Search routeToAccountById={privatePages.account} onNewDecision={(account, payto) => { - startNewRequest(); + clearDecisionDraft(officer.session.id, account); navigateTo( privatePages.decideNew.url({ cid: account, @@ -357,6 +364,128 @@ function PrivateRouting({ officer }: { officer: OfficerReady }): VNode { } } +function DecisionPage({ + account, + newPayto, + step, + formId, + onMove, + officer, +}: { + account: string; + newPayto?: string; + step?: WizardSteps; + formId: string | undefined; + officer: OfficerReady; + onMove: (n: WizardSteps | undefined) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const active = useAccountActiveDecision(newPayto ? undefined : account); + + if (!newPayto && active === undefined) return <Loading />; + if (active instanceof TalerError) { + return ( + <ErrorLoading + title={i18n.str`Failed to load the active AML decision.`} + error={active} + /> + ); + } + if (active?.type === "fail") { + return ( + <FailLoading + operation={active} + title={i18n.str`Failed to load the active AML decision.`} + translate={(failure) => { + switch (failure.case) { + case HttpStatusCode.Forbidden: + return ( + <i18n.Translate>The officer session is invalid.</i18n.Translate> + ); + case HttpStatusCode.NotFound: + return ( + <i18n.Translate>The account was not found.</i18n.Translate> + ); + case HttpStatusCode.Conflict: + return ( + <i18n.Translate> + The account state changed. Reload it and try again. + </i18n.Translate> + ); + default: + return assertUnreachable(failure.case); + } + }} + /> + ); + } + + const original = active?.type === "ok" ? active.body : undefined; + const storedTarget = readDecisionDraftTarget(officer.session.id, account); + const targetPayto = + newPayto ?? original?.full_payto ?? storedTarget?.fullPayto; + if (targetPayto) { + const parsed = Paytos.fromString(targetPayto); + const targetHash = + parsed.tag === "ok" + ? encodeCrock(Paytos.hashFull(Paytos.toNormalizedString(parsed.value))) + : undefined; + if (targetHash !== account) { + return ( + <div class="p-4 text-red-700"> + <i18n.Translate> + The payto URI does not belong to this account. Return to the account + search and start the decision again. + </i18n.Translate> + </div> + ); + } + } + if (!original && !targetPayto) { + return ( + <div class="p-4"> + <p class="text-red-700"> + <i18n.Translate> + This account has no active decision and its payto URI is unknown. + Start the decision from the account search so the account type can + be determined safely. + </i18n.Translate> + </p> + <button + class="mt-4 rounded-md bg-indigo-700 px-3 py-2 text-sm text-white" + onClick={() => onMove(undefined)} + > + <i18n.Translate>Return to account</i18n.Translate> + </button> + </div> + ); + } + + return ( + <DecisionRequestProvider + key={`${officer.session.id}:${account}`} + target={{ + officerId: officer.session.id, + account, + fullPayto: targetPayto, + }} + initial={{ + original, + custom_measures: original?.limits.custom_measures, + }} + > + <DecisionWizard + account={account} + newPayto={targetPayto} + step={step} + formId={formId} + onMove={onMove} + officer={officer} + /> + </DecisionRequestProvider> + ); +} + function Navigation(): VNode { const { i18n } = useTranslationContext(); const pageList = [ diff --git a/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts b/packages/taler-exchange-aml-webui/src/hooks/decision-request.ts @@ -32,16 +32,14 @@ import { MeasureInformation, TalerExchangeApi, } from "@gnu-taler/taler-util"; -import { - buildStorageKey, - FormErrors, - useLocalStorage, -} from "@gnu-taler/web-util/browser"; -import { useState } from "preact/hooks"; +import { FormErrors } from "@gnu-taler/web-util/browser"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { useContext, useRef, useState } from "preact/hooks"; export interface AccountAttributes { data: object; formId?: string; formVersion: number; + formSalt?: string; expiration?: AbsoluteTime; errors?: FormErrors<object>; } @@ -61,6 +59,8 @@ export interface DecisionRequest { * Next active measure */ new_measures?: string[]; + /** Combine immediate measures with AND instead of OR. */ + measures_and?: boolean; /** * Next measure after deadline */ @@ -124,8 +124,9 @@ export const codecForAccountAttributes = (): Codec<AccountAttributes> => .property("expiration", codecOptional(codecForAbsoluteTime)) .property("formId", codecOptional(codecForString())) .property("formVersion", codecForNumber()) + .property("formSalt", codecOptional(codecForString())) .property("data", codecForAny()) - .property("errors", codecForAny()) + .property("errors", codecOptional(codecForAny())) .build("AccountAttributes"); export const codecForDecisionRequest = (): Codec<DecisionRequest> => @@ -134,9 +135,9 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => .property("rules", codecOptional(codecForList(codecForKycRules()))) .property("deadline", codecOptional(codecForAbsoluteTime)) .property("properties", codecOptional(codecForMap(codecForAny()))) - .property("properties_errors", codecForAny()) + .property("properties_errors", codecOptional(codecForAny())) .property("attributes", codecOptional(codecForAccountAttributes())) - .property("custom_properties", codecForAny()) + .property("custom_properties", codecOptional(codecForMap(codecForString()))) .property("justification", codecOptional(codecForString())) .property("accountName", codecOptional(codecForString())) .property("custom_events", codecOptional(codecForList(codecForString()))) @@ -147,6 +148,7 @@ export const codecForDecisionRequest = (): Codec<DecisionRequest> => ) .property("keep_investigating", codecOptional(codecForBoolean())) .property("new_measures", codecOptional(codecForList(codecForString()))) + .property("measures_and", codecOptional(codecForBoolean())) .property("onExpire_measure", codecOptional(codecForString())) .build("DecisionRequest"); @@ -163,87 +165,198 @@ const DECISION_REQUEST_EMPTY: DecisionRequest = { justification: undefined, keep_investigating: undefined, new_measures: undefined, + measures_and: false, custom_measures: undefined, properties: undefined, rules: undefined, }; -const DECISION_REQUEST_KEY = buildStorageKey( - "aml-decision-request", - codecForDecisionRequest(), -); -/** - * This helpers is used to add support for multiple calls on the - * same update function that update and state with partial object. - * - */ -class ConcurrentUpdateHelper<T> { - prevValue: T | undefined; - public reset() { - this.prevValue = undefined; +export interface DecisionTarget { + officerId: string; + account: string; + fullPayto?: string; +} + +interface StoredDecisionDraft { + version: 1; + target: DecisionTarget; + request: DecisionRequest; +} + +const DRAFT_PREFIX = "aml-decision-draft-v1:"; +const LEGACY_DRAFT_KEY = "aml-decision-request"; + +function draftStorageKey(target: DecisionTarget): string { + return `${DRAFT_PREFIX}${target.officerId}:${target.account}`; +} + +export function decisionTargetsMatch( + a: DecisionTarget, + b: DecisionTarget, +): boolean { + return ( + a.officerId === b.officerId && + a.account === b.account && + (!a.fullPayto || !b.fullPayto || a.fullPayto === b.fullPayto) + ); +} + +export function removeLegacyDecisionDraft(): void { + try { + localStorage.removeItem(LEGACY_DRAFT_KEY); + } catch { + // Ignore unavailable storage. } - public mergeWithLatestOrDefault( - defValue: T, - newValue: Partial<T>, - ): { old: T; merged: T } { - const latest = this.prevValue === undefined ? defValue : this.prevValue; - const mergedValue = { ...latest, ...newValue }; - this.prevValue = mergedValue; - return { old: latest, merged: mergedValue }; +} + +function readDecisionDraft( + target: DecisionTarget, +): DecisionRequest | undefined { + removeLegacyDecisionDraft(); + try { + const raw = sessionStorage.getItem(draftStorageKey(target)); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as StoredDecisionDraft; + if ( + parsed.version !== 1 || + !parsed.target || + !parsed.request || + !decisionTargetsMatch(parsed.target, target) + ) { + sessionStorage.removeItem(draftStorageKey(target)); + return undefined; + } + return codecForDecisionRequest().decode(parsed.request); + } catch { + try { + sessionStorage.removeItem(draftStorageKey(target)); + } catch { + // Ignore unavailable storage. + } + return undefined; } } -const mark = new ConcurrentUpdateHelper<DecisionRequest>(); -/** - * User preferences. - * - */ -export function useCurrentDecisionRequest(): [ +function writeDecisionDraft( + target: DecisionTarget, + request: DecisionRequest, +): void { + try { + const stored: StoredDecisionDraft = { version: 1, target, request }; + sessionStorage.setItem(draftStorageKey(target), JSON.stringify(stored)); + } catch { + // Continue with an in-memory draft if session storage is unavailable. + } +} + +export function clearDecisionDraft(officerId: string, account: string): void { + try { + sessionStorage.removeItem(draftStorageKey({ officerId, account })); + } catch { + // Ignore unavailable storage. + } + removeLegacyDecisionDraft(); +} + +export function clearDecisionDraftsForOfficer(officerId: string): void { + removeLegacyDecisionDraft(); + try { + const prefix = `${DRAFT_PREFIX}${officerId}:`; + const keys: string[] = []; + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i); + if (key?.startsWith(prefix)) keys.push(key); + } + for (const key of keys) sessionStorage.removeItem(key); + } catch { + // Ignore unavailable storage. + } +} + +export function readDecisionDraftTarget( + officerId: string, + account: string, +): DecisionTarget | undefined { + removeLegacyDecisionDraft(); + try { + const raw = sessionStorage.getItem(draftStorageKey({ officerId, account })); + if (!raw) return undefined; + const parsed = JSON.parse(raw) as StoredDecisionDraft; + if ( + parsed.version !== 1 || + parsed.target?.officerId !== officerId || + parsed.target?.account !== account + ) { + return undefined; + } + return parsed.target; + } catch { + return undefined; + } +} + +type DecisionRequestContextValue = [ Readonly<DecisionRequest>, - (l: string, s: Partial<DecisionRequest>) => void, - (s?: Partial<DecisionRequest>) => void, + (label: string, update: Partial<DecisionRequest>) => void, () => void, -] { - const [currentDef, setDefault] = useState(DECISION_REQUEST_EMPTY); + () => void, +]; + +const DecisionRequestContext = createContext< + DecisionRequestContextValue | undefined +>(undefined); - const { value: request, update: setRequest } = useLocalStorage( - DECISION_REQUEST_KEY, - DECISION_REQUEST_EMPTY, +export function DecisionRequestProvider({ + target, + initial, + children, +}: { + target: DecisionTarget; + initial?: Partial<DecisionRequest>; + children: ComponentChildren; +}): VNode { + const defaultRef = useRef<DecisionRequest>({ + ...DECISION_REQUEST_EMPTY, + ...initial, + }); + const [request, setRequest] = useState<DecisionRequest>( + () => readDecisionDraft(target) ?? defaultRef.current, ); + const requestRef = useRef(request); - mark.reset(); - - function updateValue(logLabel: string, newValue: Partial<DecisionRequest>) { - /** - * "request" may not be te latest, it could happen that - * we already call "setRequest" but that call didn't update "request" yet. - * The caller didn't wait for a preact re-render. - * - * So we use the "mark" to get always an up-to-date "request". In this case - * is important since we are doing a merge update. - */ - // const old = mark.getLatestOrDefault(request) - // const mergedValue = { ...old, ...newValue }; - const { old, merged } = mark.mergeWithLatestOrDefault(request, newValue); - console.log("UPDATING DECISION REQUEST", { - logLabel, - old, - merged, - }); - setRequest(merged); - } + const setAndStore = (next: DecisionRequest): void => { + requestRef.current = next; + setRequest(next); + writeDecisionDraft(target, next); + }; - function start(d: Partial<DecisionRequest> | undefined) { - const v = d ?? DECISION_REQUEST_EMPTY; - const newDef = { ...DECISION_REQUEST_EMPTY, ...v }; - setDefault(newDef); - console.log("STARTING NEW DECISION REQUEST", newDef); - updateValue("starting", newDef); - } - function reset() { - console.log("RESETTING TO DEFAULT"); - updateValue("resetting", currentDef); - } + const update = (_label: string, partial: Partial<DecisionRequest>): void => { + setAndStore({ ...requestRef.current, ...partial }); + }; + const clear = (): void => { + clearDecisionDraft(target.officerId, target.account); + requestRef.current = DECISION_REQUEST_EMPTY; + setRequest(DECISION_REQUEST_EMPTY); + }; + const reset = (): void => setAndStore(defaultRef.current); - return [request, updateValue, start, reset]; + return h(DecisionRequestContext.Provider, { + value: [request, update, clear, reset], + children, + }); +} + +export function useCurrentDecisionRequest(): [ + Readonly<DecisionRequest>, + (l: string, s: Partial<DecisionRequest>) => void, + () => void, + () => void, +] { + const value = useContext(DecisionRequestContext); + if (!value) { + throw new Error( + "useCurrentDecisionRequest must be used inside DecisionRequestProvider", + ); + } + return value; } diff --git a/packages/taler-exchange-aml-webui/src/hooks/officer.ts b/packages/taler-exchange-aml-webui/src/hooks/officer.ts @@ -42,7 +42,18 @@ import { useExchangeApiContext, useLocalStorage, } from "@gnu-taler/web-util/browser"; -import { useEffect, useMemo } from "preact/hooks"; +import { ComponentChildren, createContext, h, VNode } from "preact"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "preact/hooks"; +import { + clearDecisionDraftsForOfficer, + removeLegacyDecisionDraft, +} from "./decision-request.js"; import { usePreferences } from "./preferences.js"; const DEFAULT_SESSION_DURATION = Duration.fromSpec({ @@ -62,7 +73,8 @@ interface OfficerCompatible { when: AbsoluteTime; } -const codecForLockedAccount = codecForString() as unknown as Codec<LockedAccount>; +const codecForLockedAccount = + codecForString() as unknown as Codec<LockedAccount>; type OfficerAccountString = { id: string; @@ -70,6 +82,8 @@ type OfficerAccountString = { unlocked: AbsoluteTime; }; +type TimedOfficerSession = OfficerSession & { unlocked: AbsoluteTime }; + export const codecForOfficerAccount = (): Codec<OfficerAccountString> => buildCodecForObject<OfficerAccountString>() .property("id", codecForString()) @@ -111,127 +125,220 @@ const DEV_ACCOUNT_KEY = buildStorageKey( codecForOfficerAccount(), ); -export function useOfficer(): OfficerState { - const { - lib: { exchange: api }, - } = useExchangeApiContext(); - const [pref] = usePreferences(); - // dev account, is kept on reloaded. - const sessionStorage = useLocalStorage(DEV_ACCOUNT_KEY); - const session = useMemo(() => { - if (!sessionStorage.value) return undefined; - - return { - id: sessionStorage.value.id as OfficerId, - __signingKey: decodeCrock(sessionStorage.value.strKey) as EddsaPrivP, - unlocked: sessionStorage.value.unlocked, - }; - }, [sessionStorage.value?.id, sessionStorage.value?.strKey]); +const SESSION_STORAGE_KEY = "aml-officer-session-v1"; - const officerStorage = useLocalStorage(OFFICER_KEY); - const officer = useMemo(() => { - if (!officerStorage.value) return undefined; - return officerStorage.value; - }, [officerStorage.value?.account, officerStorage.value?.when.t_ms]); +function removeStoredRawSession(): void { + try { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + // Raw keys were previously written to local storage under this key. + localStorage.removeItem(DEV_ACCOUNT_KEY.id); + } catch { + // Storage can be unavailable in privacy modes. The in-memory session still + // works and is the safer fallback. + } +} - const currentSession = officer?.session ?? officer?.account; - if (currentSession === undefined) { +function readStoredRawSession( + enabled: boolean, +): TimedOfficerSession | undefined { + removeLegacyRawSession(); + if (!enabled) { + removeStoredRawSession(); + return undefined; + } + try { + const raw = sessionStorage.getItem(SESSION_STORAGE_KEY); + if (!raw) return undefined; + const stored = codecForOfficerAccount().decode(JSON.parse(raw)); + const expiration = AbsoluteTime.addDuration( + stored.unlocked, + DEFAULT_SESSION_DURATION, + ); + if (AbsoluteTime.isExpired(expiration)) { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + return undefined; + } return { - state: "not-found", - create: async (pwd: Password) => { - const resp = await api.getSeed(); - const extraEntropy = resp.type === "ok" ? resp.body : new Uint8Array(); - - const { id, safe, __signingKey } = await createNewOfficerAccount( - pwd, - extraEntropy, - ); - officerStorage.update({ - account: undefined, - session: safe, - when: AbsoluteTime.now(), - }); - - // accountStorage.update({ id, signingKey }); - const strKey = encodeCrock(__signingKey); - sessionStorage.update({ id, strKey, unlocked: AbsoluteTime.now() }); - - // FIXME: This is really not the right type to use here. - return opFixedSuccess(dummyHttpResponse, id); - }, + id: stored.id as OfficerId, + __signingKey: decodeCrock(stored.strKey) as EddsaPrivP, + unlocked: stored.unlocked, }; - } else if (officer?.session === undefined) { - // migrate from account to session - officerStorage.update({ - account: undefined, - session: currentSession, - when: AbsoluteTime.now(), - }); + } catch { + removeStoredRawSession(); + return undefined; } +} - if (session === undefined) { - return { - state: "locked", - forget: () => { - officerStorage.reset(); - return opFixedSuccess(dummyHttpResponse, undefined); - }, - tryUnlock: async (pwd: Password) => { - try { - const ac = await unlockOfficerAccount(currentSession, pwd); - // accountStorage.update(ac); - sessionStorage.update({ - id: ac.id, - strKey: encodeCrock(ac.__signingKey), - unlocked: AbsoluteTime.now(), - }); - return opFixedSuccess(dummyHttpResponse, undefined); - } catch (e) { - const d = opKnownFailure(dummyHttpResponse, HttpStatusCode.Forbidden); - return d; - } - }, +function removeLegacyRawSession(): void { + try { + localStorage.removeItem(DEV_ACCOUNT_KEY.id); + } catch { + // Ignore unavailable storage. + } +} + +function writeStoredRawSession(session: TimedOfficerSession | undefined): void { + try { + if (!session) { + sessionStorage.removeItem(SESSION_STORAGE_KEY); + return; + } + const stored: OfficerAccountString = { + id: session.id, + strKey: encodeCrock(session.__signingKey), + unlocked: session.unlocked, }; + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(stored)); + } catch { + // Keep the current session memory-only when session storage is blocked. } +} + +const OfficerContext = createContext<OfficerState | undefined>(undefined); - const expiration = AbsoluteTime.addDuration( - session.unlocked, - DEFAULT_SESSION_DURATION, +export function OfficerProvider({ + children, +}: { + children: ComponentChildren; +}): VNode { + const { + lib: { exchange: api }, + } = useExchangeApiContext(); + const [pref] = usePreferences(); + const officerStorage = useLocalStorage(OFFICER_KEY); + const [rawSession, setRawSession] = useState<TimedOfficerSession | undefined>( + () => readStoredRawSession(pref.keepSessionAfterReload), ); - return { - state: "ready", - session: session, - expiration, - lock: () => { - sessionStorage.reset(); - return opFixedSuccess(dummyHttpResponse, undefined); - }, - forget: () => { - officerStorage.reset(); - sessionStorage.reset(); - return opFixedSuccess(dummyHttpResponse, undefined); - }, - }; -} + const storedOfficer = officerStorage.value; + const lockedAccount = storedOfficer?.session ?? storedOfficer?.account; + const expiration = useMemo( + () => + rawSession + ? AbsoluteTime.addDuration( + rawSession.unlocked, + DEFAULT_SESSION_DURATION, + ) + : undefined, + [rawSession], + ); + const activeSession = useMemo( + () => + rawSession && expiration && !AbsoluteTime.isExpired(expiration) + ? rawSession + : undefined, + [rawSession, expiration], + ); -export function useExpireSessionAfter1hr() { - const officer = useOfficer(); + const clearRawSession = useCallback((): void => { + if (rawSession) clearDecisionDraftsForOfficer(rawSession.id); + setRawSession(undefined); + removeStoredRawSession(); + }, [rawSession]); - const session = officer.state !== "ready" ? undefined : officer; + useEffect(() => { + removeLegacyRawSession(); + removeLegacyDecisionDraft(); + if (storedOfficer?.account && !storedOfficer.session) { + officerStorage.update({ + account: undefined, + session: storedOfficer.account, + when: storedOfficer.when, + }); + } + }, [officerStorage, storedOfficer]); useEffect(() => { - if (!session) return; - const timeLeftBeforeExpiration = Duration.getRemaining(session.expiration); + if (!pref.keepSessionAfterReload) { + writeStoredRawSession(undefined); + } else if (activeSession) { + writeStoredRawSession(activeSession); + } + }, [pref.keepSessionAfterReload, activeSession]); - if (timeLeftBeforeExpiration.d_ms === "forever") return; + useEffect(() => { + if (!activeSession || !expiration) { + if (rawSession) clearRawSession(); + return; + } + const remaining = Duration.getRemaining(expiration); + if (remaining.d_ms === "forever") return; + const timeoutId = setTimeout(clearRawSession, Math.max(0, remaining.d_ms)); + return () => clearTimeout(timeoutId); + }, [activeSession, clearRawSession, expiration, rawSession]); - const remain = timeLeftBeforeExpiration.d_ms; - const timeoutId = setTimeout(async () => { - session.lock(); - }, remain); - return () => { - clearTimeout(timeoutId); - }; - }, [session?.expiration.t_ms]); + const state: OfficerState = !lockedAccount + ? { + state: "not-found", + create: async (pwd: Password) => { + const resp = await api.getSeed(); + const extraEntropy = + resp.type === "ok" ? resp.body : new Uint8Array(); + const { id, safe, __signingKey } = await createNewOfficerAccount( + pwd, + extraEntropy, + ); + officerStorage.update({ + account: undefined, + session: safe, + when: AbsoluteTime.now(), + }); + const session: TimedOfficerSession = { + id, + __signingKey, + unlocked: AbsoluteTime.now(), + }; + setRawSession(session); + if (pref.keepSessionAfterReload) writeStoredRawSession(session); + return opFixedSuccess(dummyHttpResponse, id); + }, + } + : !activeSession || !expiration + ? { + state: "locked", + forget: () => { + officerStorage.reset(); + clearRawSession(); + return opFixedSuccess(dummyHttpResponse, undefined); + }, + tryUnlock: async (pwd: Password) => { + try { + const unlocked = await unlockOfficerAccount(lockedAccount, pwd); + const session: TimedOfficerSession = { + ...unlocked, + unlocked: AbsoluteTime.now(), + }; + setRawSession(session); + if (pref.keepSessionAfterReload) writeStoredRawSession(session); + return opFixedSuccess(dummyHttpResponse, undefined); + } catch { + return opKnownFailure( + dummyHttpResponse, + HttpStatusCode.Forbidden, + ); + } + }, + } + : { + state: "ready", + session: activeSession, + expiration, + lock: () => { + clearRawSession(); + return opFixedSuccess(dummyHttpResponse, undefined); + }, + forget: () => { + officerStorage.reset(); + clearRawSession(); + return opFixedSuccess(dummyHttpResponse, undefined); + }, + }; + + return h(OfficerContext.Provider, { value: state, children }); +} + +export function useOfficer(): OfficerState { + const state = useContext(OfficerContext); + if (!state) throw new Error("useOfficer must be used inside OfficerProvider"); + return state; }