commit 69b66e715eae039330898f470a8993d1d154b583 parent be27647ff73d1529372a80c3e145f3ee4f229a17 Author: Sebastian <sebasjm@gmail.com> Date: Fri, 26 May 2023 16:52:30 -0300 account as hook Diffstat:
13 files changed, 767 insertions(+), 696 deletions(-)
diff --git a/packages/exchange-backoffice-ui/src/account.ts b/packages/exchange-backoffice-ui/src/account.ts @@ -16,7 +16,7 @@ export interface Account { } /** - * Restore previous session and unlock account + * Restore previous session and unlock account with password * * @param salt string from which crypto params will be derived * @param key secured private key @@ -55,7 +55,7 @@ declare const opaque_SigningKey: unique symbol; export type SigningKey = Uint8Array & { [opaque_SigningKey]: true }; /** - * Create new account (secured private key) under session + * Create new account (secured private key) * secured with the given password * * @param sessionId @@ -64,8 +64,8 @@ export type SigningKey = Uint8Array & { [opaque_SigningKey]: true }; */ export async function createNewAccount( password: string, -): Promise<LockedAccount> { - const { eddsaPriv } = createEddsaKeyPair(); +): Promise<Account & { safe: LockedAccount }> { + const { eddsaPriv, eddsaPub } = createEddsaKeyPair(); const key = stringToBytes(password); @@ -76,9 +76,11 @@ export async function createNewAccount( password, ); - const protectedPriv = encodeCrock(protectedPrivKey); + const signingKey = eddsaPriv as SigningKey; + const accountId = encodeCrock(eddsaPub) as AccountId; + const safe = encodeCrock(protectedPrivKey) as LockedAccount; - return protectedPriv as LockedAccount; + return { accountId, signingKey, safe }; } export class UnwrapKeyError extends Error { diff --git a/packages/exchange-backoffice-ui/src/forms/902_1e.ts b/packages/exchange-backoffice-ui/src/forms/902_1e.ts @@ -8,7 +8,7 @@ import { FlexibleForm, languageList } from "./index.js"; import { FormState } from "../handlers/FormProvider.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/AccountDetails.js"; +import { amlStateConverter } from "../pages/CaseDetails.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({ diff --git a/packages/exchange-backoffice-ui/src/forms/902_4e.ts b/packages/exchange-backoffice-ui/src/forms/902_4e.ts @@ -11,7 +11,7 @@ import { h as create } from "preact"; import { ChevronRightIcon } from "@heroicons/react/24/solid"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/AccountDetails.js"; +import { amlStateConverter } from "../pages/CaseDetails.js"; import { Simplest, resolutionSection } from "./simplest.js"; export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({ diff --git a/packages/exchange-backoffice-ui/src/forms/simplest.ts b/packages/exchange-backoffice-ui/src/forms/simplest.ts @@ -7,7 +7,7 @@ import { import { FormState } from "../handlers/FormProvider.js"; import { FlexibleForm } from "./index.js"; import { AmlState } from "../types.js"; -import { amlStateConverter } from "../pages/AccountDetails.js"; +import { amlStateConverter } from "../pages/CaseDetails.js"; import { State } from "../pages/AntiMoneyLaunderingForm.js"; import { DoubleColumnFormSection, UIFormField } from "../handlers/forms.js"; diff --git a/packages/exchange-backoffice-ui/src/hooks/useOfficer.ts b/packages/exchange-backoffice-ui/src/hooks/useOfficer.ts @@ -0,0 +1,100 @@ +import { + AbsoluteTime, + Codec, + buildCodecForObject, + codecForAbsoluteTime, + codecForString, +} from "@gnu-taler/taler-util"; +import { + Account, + LockedAccount, + createNewAccount, + unlockAccount, +} from "../account.js"; +import { + buildStorageKey, + useLocalStorage, + useMemoryStorage, +} from "@gnu-taler/web-util/browser"; + +export interface Officer { + account: LockedAccount; + when: AbsoluteTime; +} + +const codecForLockedAccount = codecForString() as Codec<LockedAccount>; + +export const codecForOfficer = (): Codec<Officer> => + buildCodecForObject<Officer>() + .property("account", codecForLockedAccount) // FIXME + .property("when", codecForAbsoluteTime) // FIXME + .build("Officer"); + +export type OfficerState = OfficerNotReady | OfficerReady; +export type OfficerNotReady = OfficerNotFound | OfficerLocked; +interface OfficerNotFound { + state: "not-found"; + create: (password: string) => Promise<void>; +} +interface OfficerLocked { + state: "locked"; + forget: () => void; + tryUnlock: (password: string) => Promise<void>; +} +interface OfficerReady { + state: "ready"; + account: Account; + forget: () => void; + lock: () => void; +} + +const OFFICER_KEY = buildStorageKey("officer", codecForOfficer()); +const ACCOUNT_KEY = buildStorageKey<Account>("account"); + +export function useOfficer(): OfficerState { + const accountStorage = useMemoryStorage(ACCOUNT_KEY); + const officerStorage = useLocalStorage(OFFICER_KEY); + + const officer = officerStorage.value; + const account = accountStorage.value; + + if (officer === undefined) { + return { + state: "not-found", + create: async (pwd: string) => { + const { accountId, safe, signingKey } = await createNewAccount(pwd); + officerStorage.update({ + account: safe, + when: AbsoluteTime.now(), + }); + + accountStorage.update({ accountId, signingKey }); + }, + }; + } + + if (account === undefined) { + return { + state: "locked", + forget: () => { + officerStorage.reset(); + }, + tryUnlock: async (pwd: string) => { + const ac = await unlockAccount(officer.account, pwd); + accountStorage.update(ac); + }, + }; + } + + return { + state: "ready", + account: account, + lock: () => { + accountStorage.reset(); + }, + forget: () => { + officerStorage.reset(); + accountStorage.reset(); + }, + }; +} diff --git a/packages/exchange-backoffice-ui/src/pages.ts b/packages/exchange-backoffice-ui/src/pages.ts @@ -5,7 +5,7 @@ import { Welcome } from "./pages/Welcome.js"; import { PageEntry, pageDefinition } from "./route.js"; import { Officer } from "./pages/Officer.js"; import { Cases } from "./pages/Cases.js"; -import { AccountDetails } from "./pages/AccountDetails.js"; +import { CaseDetails } from "./pages/CaseDetails.js"; import { NewFormEntry } from "./pages/NewFormEntry.js"; const home: PageEntry = { @@ -18,7 +18,7 @@ const cases: PageEntry = { }; const account: PageEntry<{ account?: string }> = { url: pageDefinition("#/account/:account"), - view: AccountDetails, + view: CaseDetails, }; const newFormEntry: PageEntry<{ account?: string; type?: string }> = { diff --git a/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx b/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx @@ -1,447 +0,0 @@ -import { Fragment, VNode, h } from "preact"; -import { - AmlDecisionDetail, - AmlDecisionDetails, - AmlState, - KycDetail, -} from "../types.js"; -import { - AbsoluteTime, - AmountJson, - Amounts, - TranslatedString, -} from "@gnu-taler/taler-util"; -import { format } from "date-fns"; -import { ArrowDownCircleIcon, ClockIcon } from "@heroicons/react/20/solid"; -import { useState } from "preact/hooks"; -import { NiceForm } from "../NiceForm.js"; -import { FlexibleForm } from "../forms/index.js"; -import { UIFormField } from "../handlers/forms.js"; -import { Pages } from "../pages.js"; - -const response: AmlDecisionDetails = { - aml_history: [ - { - justification: "Lack of documentation", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000, - }, - new_state: 2, - new_threshold: "USD:0", - }, - { - justification: "Doing a transfer of high amount", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, - }, - new_state: 1, - new_threshold: "USD:2000", - }, - { - justification: "Account is known to the system", - decider_pub: "ASDASDASD", - decision_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, - }, - new_state: 0, - new_threshold: "USD:100", - }, - ], - kyc_attributes: [ - { - collection_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, - }, - expiration_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, - }, - provider_section: "asdasd", - attributes: { - name: "Sebastian", - }, - }, - { - collection_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, - }, - expiration_time: { - t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, - }, - provider_section: "asdasd", - attributes: { - creditCard: "12312312312", - }, - }, - ], -}; -type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent; -type AmlFormEvent = { - type: "aml-form"; - when: AbsoluteTime; - title: TranslatedString; - state: AmlState; - threshold: AmountJson; -}; -type KycCollectionEvent = { - type: "kyc-collection"; - when: AbsoluteTime; - title: TranslatedString; - values: object; - provider: string; -}; -type KycExpirationEvent = { - type: "kyc-expiration"; - when: AbsoluteTime; - title: TranslatedString; - fields: string[]; -}; - -type WithTime = { when: AbsoluteTime }; - -function selectSooner(a: WithTime, b: WithTime) { - return AbsoluteTime.cmp(a.when, b.when); -} - -function getEventsFromAmlHistory( - aml: AmlDecisionDetail[], - kyc: KycDetail[], -): AmlEvent[] { - const ae: AmlEvent[] = aml.map((a) => { - return { - type: "aml-form", - state: a.new_state, - threshold: Amounts.parseOrThrow(a.new_threshold), - title: a.justification as TranslatedString, - when: { - t_ms: - a.decision_time.t_s === "never" - ? "never" - : a.decision_time.t_s * 1000, - }, - } as AmlEvent; - }); - const ke = kyc.reduce((prev, k) => { - prev.push({ - type: "kyc-collection", - title: "collection" as TranslatedString, - when: AbsoluteTime.fromProtocolTimestamp(k.collection_time), - values: !k.attributes ? {} : k.attributes, - provider: k.provider_section, - }); - prev.push({ - type: "kyc-expiration", - title: "expired" as TranslatedString, - when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), - fields: !k.attributes ? [] : Object.keys(k.attributes), - }); - return prev; - }, [] as AmlEvent[]); - return ae.concat(ke).sort(selectSooner); -} - -export function AccountDetails({ account }: { account?: string }) { - const events = getEventsFromAmlHistory( - response.aml_history, - response.kyc_attributes, - ); - console.log("DETAILS", events, events[events.length - 1 - 2]); - const [selected, setSelected] = useState<AmlEvent>( - events[events.length - 1 - 2], - ); - return ( - <div> - <a - href={Pages.newFormEntry.url({ account })} - class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" - > - New AML form - </a> - - <header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8"> - <h1 class="text-base font-semibold leading-7 text-black"> - Case history - </h1> - </header> - <div class="flow-root"> - <ul role="list"> - {events.map((e, idx) => { - const isLast = events.length - 1 === idx; - return ( - <li - class="hover:bg-gray-200 p-2 rounded cursor-pointer" - onClick={() => { - setSelected(e); - }} - > - <div class="relative pb-6"> - {!isLast ? ( - <span - class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200" - aria-hidden="true" - ></span> - ) : undefined} - <div class="relative flex space-x-3"> - {(() => { - switch (e.type) { - case "aml-form": { - switch (e.state) { - case AmlState.normal: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> - Normal - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - case AmlState.pending: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> - Pending - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - case AmlState.frozen: { - return ( - <div> - <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> - Frozen - </span> - <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> - {e.threshold.currency}{" "} - {Amounts.stringifyValue(e.threshold)} - </span> - </div> - ); - } - } - } - case "kyc-collection": { - return ( - <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> - ); - } - case "kyc-expiration": { - return <ClockIcon class="h-8 w-8 text-gray-700" />; - } - } - })()} - <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> - <div> - <p class="text-sm text-gray-900">{e.title}</p> - </div> - <div class="whitespace-nowrap text-right text-sm text-gray-500"> - {e.when.t_ms === "never" ? ( - "never" - ) : ( - <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> - {format(e.when.t_ms, "dd MMM yyyy")} - </time> - )} - </div> - </div> - </div> - </div> - </li> - ); - })} - </ul> - </div> - {selected && <ShowEventDetails event={selected} />} - {selected && <ShowConsolidated history={events} until={selected} />} - </div> - ); -} - -function ShowEventDetails({ event }: { event: AmlEvent }): VNode { - return <div>type {event.type}</div>; -} - -function ShowConsolidated({ - history, - until, -}: { - history: AmlEvent[]; - until: AmlEvent; -}): VNode { - console.log("UNTIL", until); - const cons = getConsolidated(history, until.when); - - const form: FlexibleForm<Consolidated> = { - versionId: "1", - behavior: (form) => { - return {}; - }, - design: [ - { - title: "AML" as TranslatedString, - fields: [ - { - type: "amount", - props: { - label: "Threshold" as TranslatedString, - name: "aml.threshold", - }, - }, - { - type: "choiceHorizontal", - props: { - label: "State" as TranslatedString, - name: "aml.state", - converter: amlStateConverter, - choices: [ - { - label: "Frozen" as TranslatedString, - value: AmlState.frozen, - }, - { - label: "Pending" as TranslatedString, - value: AmlState.pending, - }, - { - label: "Normal" as TranslatedString, - value: AmlState.normal, - }, - ], - }, - }, - ], - }, - Object.entries(cons.kyc).length > 0 - ? { - title: "KYC" as TranslatedString, - fields: Object.entries(cons.kyc).map(([key, field]) => { - const result: UIFormField = { - type: "text", - props: { - label: key as TranslatedString, - name: `kyc.${key}.value`, - help: `${field.provider} since ${ - field.since.t_ms === "never" - ? "never" - : format(field.since.t_ms, "dd/MM/yyyy") - }` as TranslatedString, - }, - }; - return result; - }), - } - : undefined, - ], - }; - return ( - <Fragment> - <h1 class="text-base font-semibold leading-7 text-black"> - Consolidated information after{" "} - {until.when.t_ms === "never" - ? "never" - : format(until.when.t_ms, "dd MMMM yyyy")} - </h1> - <NiceForm - key={`${String(Date.now())}`} - form={form} - initial={cons} - onUpdate={() => {}} - /> - </Fragment> - ); -} - -interface Consolidated { - aml: { - state?: AmlState; - threshold?: AmountJson; - since: AbsoluteTime; - }; - kyc: { - [field: string]: { - value: any; - provider: string; - since: AbsoluteTime; - }; - }; -} - -function getConsolidated( - history: AmlEvent[], - when: AbsoluteTime, -): Consolidated { - const initial: Consolidated = { - aml: { - since: AbsoluteTime.never(), - }, - kyc: {}, - }; - return history.reduce((prev, cur) => { - if (AbsoluteTime.cmp(when, cur.when) < 0) { - return prev; - } - switch (cur.type) { - case "kyc-expiration": { - cur.fields.forEach((field) => { - delete prev.kyc[field]; - }); - break; - } - case "aml-form": { - prev.aml.threshold = cur.threshold; - prev.aml.state = cur.state; - prev.aml.since = cur.when; - break; - } - case "kyc-collection": { - Object.keys(cur.values).forEach((field) => { - prev.kyc[field] = { - value: (cur.values as any)[field], - provider: cur.provider, - since: cur.when, - }; - }); - break; - } - } - return prev; - }, initial); -} - -export const amlStateConverter = { - toStringUI: stringifyAmlState, - fromStringUI: parseAmlState, -}; - -function stringifyAmlState(s: AmlState | undefined): string { - if (s === undefined) return ""; - switch (s) { - case AmlState.normal: - return "normal"; - case AmlState.pending: - return "pending"; - case AmlState.frozen: - return "frozen"; - } -} - -function parseAmlState(s: string | undefined): AmlState { - switch (s) { - case "normal": - return AmlState.normal; - case "pending": - return AmlState.pending; - case "frozen": - return AmlState.frozen; - default: - throw Error(`unknown AML state: ${s}`); - } -} diff --git a/packages/exchange-backoffice-ui/src/pages/CaseDetails.tsx b/packages/exchange-backoffice-ui/src/pages/CaseDetails.tsx @@ -0,0 +1,447 @@ +import { Fragment, VNode, h } from "preact"; +import { + AmlDecisionDetail, + AmlDecisionDetails, + AmlState, + KycDetail, +} from "../types.js"; +import { + AbsoluteTime, + AmountJson, + Amounts, + TranslatedString, +} from "@gnu-taler/taler-util"; +import { format } from "date-fns"; +import { ArrowDownCircleIcon, ClockIcon } from "@heroicons/react/20/solid"; +import { useState } from "preact/hooks"; +import { NiceForm } from "../NiceForm.js"; +import { FlexibleForm } from "../forms/index.js"; +import { UIFormField } from "../handlers/forms.js"; +import { Pages } from "../pages.js"; + +const response: AmlDecisionDetails = { + aml_history: [ + { + justification: "Lack of documentation", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000, + }, + new_state: 2, + new_threshold: "USD:0", + }, + { + justification: "Doing a transfer of high amount", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 6, + }, + new_state: 1, + new_threshold: "USD:2000", + }, + { + justification: "Account is known to the system", + decider_pub: "ASDASDASD", + decision_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 9, + }, + new_state: 0, + new_threshold: "USD:100", + }, + ], + kyc_attributes: [ + { + collection_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 8, + }, + expiration_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 4, + }, + provider_section: "asdasd", + attributes: { + name: "Sebastian", + }, + }, + { + collection_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 5, + }, + expiration_time: { + t_s: Date.now() / 1000 - 60 * 60 * 24 * 30 * 2, + }, + provider_section: "asdasd", + attributes: { + creditCard: "12312312312", + }, + }, + ], +}; +type AmlEvent = AmlFormEvent | KycCollectionEvent | KycExpirationEvent; +type AmlFormEvent = { + type: "aml-form"; + when: AbsoluteTime; + title: TranslatedString; + state: AmlState; + threshold: AmountJson; +}; +type KycCollectionEvent = { + type: "kyc-collection"; + when: AbsoluteTime; + title: TranslatedString; + values: object; + provider: string; +}; +type KycExpirationEvent = { + type: "kyc-expiration"; + when: AbsoluteTime; + title: TranslatedString; + fields: string[]; +}; + +type WithTime = { when: AbsoluteTime }; + +function selectSooner(a: WithTime, b: WithTime) { + return AbsoluteTime.cmp(a.when, b.when); +} + +function getEventsFromAmlHistory( + aml: AmlDecisionDetail[], + kyc: KycDetail[], +): AmlEvent[] { + const ae: AmlEvent[] = aml.map((a) => { + return { + type: "aml-form", + state: a.new_state, + threshold: Amounts.parseOrThrow(a.new_threshold), + title: a.justification as TranslatedString, + when: { + t_ms: + a.decision_time.t_s === "never" + ? "never" + : a.decision_time.t_s * 1000, + }, + } as AmlEvent; + }); + const ke = kyc.reduce((prev, k) => { + prev.push({ + type: "kyc-collection", + title: "collection" as TranslatedString, + when: AbsoluteTime.fromProtocolTimestamp(k.collection_time), + values: !k.attributes ? {} : k.attributes, + provider: k.provider_section, + }); + prev.push({ + type: "kyc-expiration", + title: "expired" as TranslatedString, + when: AbsoluteTime.fromProtocolTimestamp(k.expiration_time), + fields: !k.attributes ? [] : Object.keys(k.attributes), + }); + return prev; + }, [] as AmlEvent[]); + return ae.concat(ke).sort(selectSooner); +} + +export function CaseDetails({ account }: { account?: string }) { + const events = getEventsFromAmlHistory( + response.aml_history, + response.kyc_attributes, + ); + console.log("DETAILS", events, events[events.length - 1 - 2]); + const [selected, setSelected] = useState<AmlEvent>( + events[events.length - 1 - 2], + ); + return ( + <div> + <a + href={Pages.newFormEntry.url({ account })} + class="m-4 block rounded-md w-fit border-0 px-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-700" + > + New AML form + </a> + + <header class="flex items-center justify-between border-b border-white/5 px-4 py-4 sm:px-6 sm:py-6 lg:px-8"> + <h1 class="text-base font-semibold leading-7 text-black"> + Case history + </h1> + </header> + <div class="flow-root"> + <ul role="list"> + {events.map((e, idx) => { + const isLast = events.length - 1 === idx; + return ( + <li + class="hover:bg-gray-200 p-2 rounded cursor-pointer" + onClick={() => { + setSelected(e); + }} + > + <div class="relative pb-6"> + {!isLast ? ( + <span + class="absolute left-4 top-4 -ml-px h-full w-1 bg-gray-200" + aria-hidden="true" + ></span> + ) : undefined} + <div class="relative flex space-x-3"> + {(() => { + switch (e.type) { + case "aml-form": { + switch (e.state) { + case AmlState.normal: { + return ( + <div> + <span class="inline-flex items-center rounded-md bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20"> + Normal + </span> + <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> + {e.threshold.currency}{" "} + {Amounts.stringifyValue(e.threshold)} + </span> + </div> + ); + } + case AmlState.pending: { + return ( + <div> + <span class="inline-flex items-center rounded-md bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-green-600/20"> + Pending + </span> + <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> + {e.threshold.currency}{" "} + {Amounts.stringifyValue(e.threshold)} + </span> + </div> + ); + } + case AmlState.frozen: { + return ( + <div> + <span class="inline-flex items-center rounded-md bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-green-600/20"> + Frozen + </span> + <span class="inline-flex items-center px-2 py-1 text-xs font-medium text-gray-700 "> + {e.threshold.currency}{" "} + {Amounts.stringifyValue(e.threshold)} + </span> + </div> + ); + } + } + } + case "kyc-collection": { + return ( + <ArrowDownCircleIcon class="h-8 w-8 text-green-700" /> + ); + } + case "kyc-expiration": { + return <ClockIcon class="h-8 w-8 text-gray-700" />; + } + } + })()} + <div class="flex min-w-0 flex-1 justify-between space-x-4 pt-1.5"> + <div> + <p class="text-sm text-gray-900">{e.title}</p> + </div> + <div class="whitespace-nowrap text-right text-sm text-gray-500"> + {e.when.t_ms === "never" ? ( + "never" + ) : ( + <time dateTime={format(e.when.t_ms, "dd MMM yyyy")}> + {format(e.when.t_ms, "dd MMM yyyy")} + </time> + )} + </div> + </div> + </div> + </div> + </li> + ); + })} + </ul> + </div> + {selected && <ShowEventDetails event={selected} />} + {selected && <ShowConsolidated history={events} until={selected} />} + </div> + ); +} + +function ShowEventDetails({ event }: { event: AmlEvent }): VNode { + return <div>type {event.type}</div>; +} + +function ShowConsolidated({ + history, + until, +}: { + history: AmlEvent[]; + until: AmlEvent; +}): VNode { + console.log("UNTIL", until); + const cons = getConsolidated(history, until.when); + + const form: FlexibleForm<Consolidated> = { + versionId: "1", + behavior: (form) => { + return {}; + }, + design: [ + { + title: "AML" as TranslatedString, + fields: [ + { + type: "amount", + props: { + label: "Threshold" as TranslatedString, + name: "aml.threshold", + }, + }, + { + type: "choiceHorizontal", + props: { + label: "State" as TranslatedString, + name: "aml.state", + converter: amlStateConverter, + choices: [ + { + label: "Frozen" as TranslatedString, + value: AmlState.frozen, + }, + { + label: "Pending" as TranslatedString, + value: AmlState.pending, + }, + { + label: "Normal" as TranslatedString, + value: AmlState.normal, + }, + ], + }, + }, + ], + }, + Object.entries(cons.kyc).length > 0 + ? { + title: "KYC" as TranslatedString, + fields: Object.entries(cons.kyc).map(([key, field]) => { + const result: UIFormField = { + type: "text", + props: { + label: key as TranslatedString, + name: `kyc.${key}.value`, + help: `${field.provider} since ${ + field.since.t_ms === "never" + ? "never" + : format(field.since.t_ms, "dd/MM/yyyy") + }` as TranslatedString, + }, + }; + return result; + }), + } + : undefined, + ], + }; + return ( + <Fragment> + <h1 class="text-base font-semibold leading-7 text-black"> + Consolidated information after{" "} + {until.when.t_ms === "never" + ? "never" + : format(until.when.t_ms, "dd MMMM yyyy")} + </h1> + <NiceForm + key={`${String(Date.now())}`} + form={form} + initial={cons} + onUpdate={() => {}} + /> + </Fragment> + ); +} + +interface Consolidated { + aml: { + state?: AmlState; + threshold?: AmountJson; + since: AbsoluteTime; + }; + kyc: { + [field: string]: { + value: any; + provider: string; + since: AbsoluteTime; + }; + }; +} + +function getConsolidated( + history: AmlEvent[], + when: AbsoluteTime, +): Consolidated { + const initial: Consolidated = { + aml: { + since: AbsoluteTime.never(), + }, + kyc: {}, + }; + return history.reduce((prev, cur) => { + if (AbsoluteTime.cmp(when, cur.when) < 0) { + return prev; + } + switch (cur.type) { + case "kyc-expiration": { + cur.fields.forEach((field) => { + delete prev.kyc[field]; + }); + break; + } + case "aml-form": { + prev.aml.threshold = cur.threshold; + prev.aml.state = cur.state; + prev.aml.since = cur.when; + break; + } + case "kyc-collection": { + Object.keys(cur.values).forEach((field) => { + prev.kyc[field] = { + value: (cur.values as any)[field], + provider: cur.provider, + since: cur.when, + }; + }); + break; + } + } + return prev; + }, initial); +} + +export const amlStateConverter = { + toStringUI: stringifyAmlState, + fromStringUI: parseAmlState, +}; + +function stringifyAmlState(s: AmlState | undefined): string { + if (s === undefined) return ""; + switch (s) { + case AmlState.normal: + return "normal"; + case AmlState.pending: + return "pending"; + case AmlState.frozen: + return "frozen"; + } +} + +function parseAmlState(s: string | undefined): AmlState { + switch (s) { + case "normal": + return AmlState.normal; + case "pending": + return AmlState.pending; + case "frozen": + return AmlState.frozen; + default: + throw Error(`unknown AML state: ${s}`); + } +} diff --git a/packages/exchange-backoffice-ui/src/pages/Cases.tsx b/packages/exchange-backoffice-ui/src/pages/Cases.tsx @@ -4,8 +4,10 @@ import { AmlRecords, AmlState } from "../types.js"; import { InputChoiceHorizontal } from "../handlers/InputChoiceHorizontal.js"; import { createNewForm } from "../handlers/forms.js"; import { TranslatedString } from "@gnu-taler/taler-util"; -import { amlStateConverter as amlStateConverter } from "./AccountDetails.js"; +import { amlStateConverter as amlStateConverter } from "./CaseDetails.js"; import { useState } from "preact/hooks"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; +import { useOfficer } from "../hooks/useOfficer.js"; const response: AmlRecords = { records: [ @@ -61,6 +63,10 @@ function doFilter( } export function Cases() { + const officer = useOfficer(); + if (officer.state !== "ready") { + return <HandleAccountNotReady officer={officer} />; + } const form = createNewForm<{ state: AmlState; }>(); diff --git a/packages/exchange-backoffice-ui/src/pages/CreateAccount.tsx b/packages/exchange-backoffice-ui/src/pages/CreateAccount.tsx @@ -0,0 +1,89 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { createNewForm } from "../handlers/forms.js"; + +export function CreateAccount({ + onNewAccount, +}: { + onNewAccount: (password: string) => void; +}): VNode { + const { i18n } = useTranslationContext(); + const Form = createNewForm<{ + password: string; + repeat: string; + }>(); + + return ( + <div class="flex min-h-full flex-col "> + <div class="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> + Create account + </h2> + </div> + + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> + <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> + <Form.Provider + computeFormState={(v) => { + return { + password: { + error: !v.password + ? i18n.str`required` + : v.password.length < 8 + ? i18n.str`should have at least 8 characters` + : !v.password.match(/[a-z]/) && v.password.match(/[A-Z]/) + ? i18n.str`should have lowercase and uppercase characters` + : !v.password.match(/\d/) + ? i18n.str`should have numbers` + : !v.password.match(/[^a-zA-Z\d]/) + ? i18n.str`should have at least one character which is not a number or letter` + : undefined, + }, + repeat: { + // error: !v.repeat + // ? i18n.str`required` + // // : v.repeat !== v.password + // // ? i18n.str`doesn't match` + // : undefined, + }, + }; + }} + onSubmit={async (v) => { + onNewAccount(v.password); + }} + > + <div class="mb-4"> + <Form.InputLine + label={"Password" as TranslatedString} + name="password" + type="password" + help={ + "lower and upper case letters, number and special character" as TranslatedString + } + required + /> + </div> + <div class="mb-4"> + <Form.InputLine + label={"Repeat password" as TranslatedString} + name="repeat" + type="password" + required + /> + </div> + + <div class="mt-8"> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + Create + </button> + </div> + </Form.Provider> + </div> + </div> + </div> + ); +} diff --git a/packages/exchange-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/exchange-backoffice-ui/src/pages/HandleAccountNotReady.tsx @@ -0,0 +1,31 @@ +import { VNode, h } from "preact"; +import { OfficerNotReady } from "../hooks/useOfficer.js"; +import { CreateAccount } from "./CreateAccount.js"; +import { UnlockAccount } from "./UnlockAccount.js"; + +export function HandleAccountNotReady({ + officer, +}: { + officer: OfficerNotReady; +}): VNode { + if (officer.state === "not-found") { + return ( + <CreateAccount + onNewAccount={(password) => { + officer.create(password); + }} + /> + ); + } + + if (officer.state === "locked") { + return ( + <UnlockAccount + onAccountUnlocked={(pwd) => { + officer.tryUnlock(pwd); + }} + /> + ); + } + throw Error(`unexpected account state ${(officer as any).state}`); +} diff --git a/packages/exchange-backoffice-ui/src/pages/Officer.tsx b/packages/exchange-backoffice-ui/src/pages/Officer.tsx @@ -1,83 +1,11 @@ -import { - AbsoluteTime, - Codec, - TranslatedString, - buildCodecForObject, - codecForAbsoluteTime, - codecForString, -} from "@gnu-taler/taler-util"; -import { - notifyError, - notifyInfo, - useLocalStorage, - useMemoryStorage, - useTranslationContext, -} from "@gnu-taler/web-util/browser"; -import { VNode, h } from "preact"; -import { useEffect, useState } from "preact/hooks"; -import { - Account, - LockedAccount, - UnwrapKeyError, - createNewAccount, - unlockAccount, -} from "../account.js"; -import { createNewForm } from "../handlers/forms.js"; - -export interface Officer { - account: LockedAccount; - when: AbsoluteTime; -} - -const codecForLockedAccount = codecForString() as Codec<LockedAccount>; - -export const codecForOfficer = (): Codec<Officer> => - buildCodecForObject<Officer>() - .property("account", codecForLockedAccount) // FIXME - .property("when", codecForAbsoluteTime) // FIXME - .build("Officer"); +import { Fragment, h } from "preact"; +import { useOfficer } from "../hooks/useOfficer.js"; +import { HandleAccountNotReady } from "./HandleAccountNotReady.js"; export function Officer() { - const password = useMemoryStorage("password"); - const officer = useLocalStorage("officer", { - codec: codecForOfficer(), - }); - const [keys, setKeys] = useState<Account>(); - - useEffect(() => { - if (officer.value === undefined || password.value === undefined) { - return; - } - - unlockAccount(officer.value.account, password.value) - .then((keys) => setKeys(keys ?? { accountId: "", pub: "" })) - .catch((e) => { - if (e instanceof UnwrapKeyError) { - console.log(e); - } - }); - }, [officer.value, password.value]); - - if (officer.value === undefined || !officer.value.account) { - return ( - <CreateAccount - onNewAccount={(account, pwd) => { - password.update(pwd); - officer.update({ account, when: AbsoluteTime.now() }); - }} - /> - ); - } - - if (password.value === undefined) { - return ( - <UnlockAccount - lockedAccount={officer.value.account} - onAccountUnlocked={(pwd) => { - password.update(pwd); - }} - /> - ); + const officer = useOfficer(); + if (officer.state !== "ready") { + return <HandleAccountNotReady officer={officer} />; } return ( @@ -86,12 +14,12 @@ export function Officer() { Public key </h1> <div class="max-w-xl text-base leading-7 text-gray-700 lg:max-w-lg"> - <p class="mt-6 font-mono break-all">{keys?.accountId}</p> + <p class="mt-6 font-mono break-all">{officer.account.accountId}</p> </div> <p> <a href={`mailto:aml@exchange.taler.net?body=${encodeURIComponent( - `I want my AML account\n\n\nPubKey: ${keys?.accountId}`, + `I want my AML account\n\n\nPubKey: ${officer.account.accountId}`, )}`} target="_blank" rel="noreferrer" @@ -104,7 +32,7 @@ export function Officer() { <button type="button" onClick={() => { - password.reset(); + officer.lock(); }} class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm " > @@ -115,7 +43,7 @@ export function Officer() { <button type="button" onClick={() => { - officer.reset(); + officer.forget(); }} class="m-4 block rounded-md bg-red-600 px-3 py-2 text-center text-sm text-white shadow-sm hover:bg-red-500 " > @@ -125,158 +53,3 @@ export function Officer() { </div> ); } - -function CreateAccount({ - onNewAccount, -}: { - onNewAccount: (account: LockedAccount, password: string) => void; -}): VNode { - const { i18n } = useTranslationContext(); - const Form = createNewForm<{ - password: string; - repeat: string; - }>(); - - return ( - <div class="flex min-h-full flex-col "> - <div class="sm:mx-auto sm:w-full sm:max-w-md"> - <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> - Create account - </h2> - </div> - - <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> - <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> - <Form.Provider - computeFormState={(v) => { - return { - password: { - error: !v.password - ? i18n.str`required` - : v.password.length < 8 - ? i18n.str`should have at least 8 characters` - : !v.password.match(/[a-z]/) && v.password.match(/[A-Z]/) - ? i18n.str`should have lowercase and uppercase characters` - : !v.password.match(/\d/) - ? i18n.str`should have numbers` - : !v.password.match(/[^a-zA-Z\d]/) - ? i18n.str`should have at least one character which is not a number or letter` - : undefined, - }, - repeat: { - // error: !v.repeat - // ? i18n.str`required` - // // : v.repeat !== v.password - // // ? i18n.str`doesn't match` - // : undefined, - }, - }; - }} - onSubmit={async (v) => { - const account = await createNewAccount(v.password); - onNewAccount(account, v.password); - }} - > - <div class="mb-4"> - <Form.InputLine - label={"Password" as TranslatedString} - name="password" - type="password" - help={ - "lower and upper case letters, number and special character" as TranslatedString - } - required - /> - </div> - <div class="mb-4"> - <Form.InputLine - label={"Repeat password" as TranslatedString} - name="repeat" - type="password" - required - /> - </div> - - <div class="mt-8"> - <button - type="submit" - class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - > - Create - </button> - </div> - </Form.Provider> - </div> - </div> - </div> - ); -} - -function UnlockAccount({ - lockedAccount, - onAccountUnlocked, -}: { - lockedAccount: LockedAccount; - onAccountUnlocked: (password: string) => void; -}): VNode { - const Form = createNewForm<{ - password: string; - }>(); - - return ( - <div class="flex min-h-full flex-col "> - <div class="sm:mx-auto sm:w-full sm:max-w-md"> - <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> - Account locked - </h2> - <p class="mt-6 text-lg leading-8 text-gray-600"> - Your account is normally locked anytime you reload. To unlock type - your password again. - </p> - </div> - - <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> - <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> - <Form.Provider - onSubmit={async (v) => { - try { - // test login - await unlockAccount(lockedAccount, v.password); - - onAccountUnlocked(v.password ?? ""); - notifyInfo("Account unlocked" as TranslatedString); - } catch (e) { - if (e instanceof UnwrapKeyError) { - notifyError( - "Could not unlock account" as any, - e.message as any, - ); - } else { - throw e; - } - } - }} - > - <div class="mb-4"> - <Form.InputLine - label={"Password" as TranslatedString} - name="password" - type="password" - required - /> - </div> - - <div class="mt-8"> - <button - type="submit" - class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - > - Unlock - </button> - </div> - </Form.Provider> - </div> - </div> - </div> - ); -} diff --git a/packages/exchange-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/exchange-backoffice-ui/src/pages/UnlockAccount.tsx @@ -0,0 +1,70 @@ +import { TranslatedString } from "@gnu-taler/taler-util"; +import { notifyError, notifyInfo } from "@gnu-taler/web-util/browser"; +import { VNode, h } from "preact"; +import { UnwrapKeyError } from "../account.js"; +import { createNewForm } from "../handlers/forms.js"; + +export function UnlockAccount({ + onAccountUnlocked, +}: { + onAccountUnlocked: (password: string) => void; +}): VNode { + const Form = createNewForm<{ + password: string; + }>(); + + return ( + <div class="flex min-h-full flex-col "> + <div class="sm:mx-auto sm:w-full sm:max-w-md"> + <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900"> + Account locked + </h2> + <p class="mt-6 text-lg leading-8 text-gray-600"> + Your account is normally locked anytime you reload. To unlock type + your password again. + </p> + </div> + + <div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> + <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12"> + <Form.Provider + onSubmit={async (v) => { + try { + await onAccountUnlocked(v.password); + + notifyInfo("Account unlocked" as TranslatedString); + } catch (e) { + if (e instanceof UnwrapKeyError) { + notifyError( + "Could not unlock account" as any, + e.message as any, + ); + } else { + throw e; + } + } + }} + > + <div class="mb-4"> + <Form.InputLine + label={"Password" as TranslatedString} + name="password" + type="password" + required + /> + </div> + + <div class="mt-8"> + <button + type="submit" + class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + > + Unlock + </button> + </div> + </Form.Provider> + </div> + </div> + </div> + ); +}