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:
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;
}