diff options
author | Sebastian <sebasjm@gmail.com> | 2023-05-25 18:08:20 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2023-05-26 09:26:09 -0300 |
commit | 64e3705669e7c12b8013704654f17cf8eaf659d4 (patch) | |
tree | b0572d228b34740f307da4c59e6e5fa0e3e1f808 /packages/exchange-backoffice-ui/src/pages | |
parent | dad7d48ed2d7cd6f17466889395b49023e4b5097 (diff) | |
download | wallet-core-64e3705669e7c12b8013704654f17cf8eaf659d4.tar.xz |
cases, account details and new-form screen
Diffstat (limited to 'packages/exchange-backoffice-ui/src/pages')
6 files changed, 942 insertions, 106 deletions
diff --git a/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx b/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx new file mode 100644 index 000000000..8b9b01ae6 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx @@ -0,0 +1,457 @@ +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: { + t_ms: + k.collection_time.t_s === "never" + ? "never" + : k.collection_time.t_s * 1000, + }, + values: !k.attributes ? {} : k.attributes, + provider: k.provider_section, + }); + prev.push({ + type: "kyc-expiration", + title: "expired" as TranslatedString, + when: { + t_ms: + k.expiration_time.t_s === "never" + ? "never" + : k.expiration_time.t_s * 1000, + }, + 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/AntiMoneyLaunderingForm.tsx b/packages/exchange-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx index fc5838dd9..713c0d7c1 100644 --- a/packages/exchange-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx +++ b/packages/exchange-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx @@ -8,8 +8,11 @@ import { v1 as form_902_1e_v1 } from "../forms/902_1e.js"; import { v1 as form_902_4e_v1 } from "../forms/902_4e.js"; import { v1 as form_902_5e_v1 } from "../forms/902_5e.js"; import { v1 as form_902_9e_v1 } from "../forms/902_9e.js"; +import { v1 as simplest } from "../forms/simplest.js"; import { DocumentDuplicateIcon } from "@heroicons/react/24/solid"; import { AbsoluteTime } from "@gnu-taler/taler-util"; +import { AmlState } from "../types.js"; +import { AmountJson, Amounts } from "@gnu-taler/taler-util"; export function AntiMoneyLaunderingForm({ number }: { number?: string }) { const selectedForm = Number.parseInt(number ?? "0", 10); @@ -22,12 +25,29 @@ export function AntiMoneyLaunderingForm({ number }: { number?: string }) { when: AbsoluteTime.now(), }; return ( - <NiceForm initial={storedValue} form={showingFrom} onUpdate={() => {}} /> + <NiceForm + initial={storedValue} + form={showingFrom({ + state: AmlState.pending, + threshold: Amounts.parseOrThrow("USD:10"), + })} + onUpdate={() => {}} + /> ); } +export interface State { + state: AmlState; + threshold: AmountJson; +} + export const allForms = [ { + name: "Simple comment", + icon: DocumentDuplicateIcon, + impl: simplest, + }, + { name: "Identification form (902.1e)", icon: DocumentDuplicateIcon, impl: form_902_1e_v1, diff --git a/packages/exchange-backoffice-ui/src/pages/Cases.tsx b/packages/exchange-backoffice-ui/src/pages/Cases.tsx new file mode 100644 index 000000000..1983769ed --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/Cases.tsx @@ -0,0 +1,282 @@ +import { VNode, h } from "preact"; +import { Pages } from "../pages.js"; +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 { useState } from "preact/hooks"; + +const response: AmlRecords = { + records: [ + { + current_state: 0, + h_payto: "QWEQWEQWEQWEWQE", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 1, + h_payto: "ASDASDASD", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 2, + h_payto: "ZXCZXCZXCXZC", + rowid: 1, + threshold: "USD 1000", + }, + { + current_state: 0, + h_payto: "QWEQWEQWEQWEWQE", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 1, + h_payto: "ASDASDASD", + rowid: 1, + threshold: "USD 100", + }, + { + current_state: 2, + h_payto: "ZXCZXCZXCXZC", + rowid: 1, + threshold: "USD 1000", + }, + ].map((e, idx) => { + e.rowid = idx; + e.threshold = `${e.threshold}${idx}`; + return e; + }), +}; + +function doFilter( + list: typeof response.records, + filter: AmlState | undefined, +): typeof response.records { + if (filter === undefined) return list; + return list.filter((r) => r.current_state === filter); +} + +export function Cases() { + const form = createNewForm<{ + state: AmlState; + }>(); + const initial = { state: AmlState.pending }; + const [list, setList] = useState(doFilter(response.records, initial.state)); + return ( + <div> + <div class="px-4 sm:px-6 lg:px-8"> + <div class="sm:flex sm:items-center"> + <div class="sm:flex-auto"> + <h1 class="text-base font-semibold leading-6 text-gray-900"> + Cases + </h1> + <p class="mt-2 text-sm text-gray-700"> + A list of all the account with the status + </p> + </div> + <form.Provider + initialValue={initial} + onUpdate={(v) => { + setList(doFilter(response.records, v.state)); + }} + onSubmit={(v) => {}} + > + <form.InputChoiceHorizontal + name="state" + label={"Filter" as TranslatedString} + converter={amlStateConverter} + choices={[ + { + label: "Pending" as TranslatedString, + value: AmlState.pending, + }, + { + label: "Frozen" as TranslatedString, + value: AmlState.frozen, + }, + { + label: "Normal" as TranslatedString, + value: AmlState.normal, + }, + ]} + /> + </form.Provider> + </div> + <div class="mt-8 flow-root"> + <div class="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> + <div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> + <Pagination /> + <table class="min-w-full divide-y divide-gray-300"> + <thead> + <tr> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Account Id + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Status + </th> + <th + scope="col" + class="px-3 py-3.5 text-left text-sm font-semibold text-gray-900" + > + Threshold + </th> + </tr> + </thead> + <tbody class="divide-y divide-gray-200 bg-white"> + {list.map((r) => { + return ( + <tr class="hover:bg-gray-100 "> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500 "> + <div class="text-gray-900"> + <a + href={Pages.details.url({ account: r.h_payto })} + class="text-indigo-600 hover:text-indigo-900" + > + {r.h_payto} + </a> + </div> + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-500"> + {((state: AmlState): VNode => { + switch (state) { + case AmlState.normal: { + return ( + <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> + ); + } + case AmlState.pending: { + return ( + <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> + ); + } + case AmlState.frozen: { + return ( + <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> + ); + } + } + })(r.current_state)} + </td> + <td class="whitespace-nowrap px-3 py-5 text-sm text-gray-900"> + {r.threshold} + </td> + </tr> + ); + })} + </tbody> + </table> + <Pagination /> + </div> + </div> + </div> + </div> + </div> + ); +} + +function Pagination() { + return ( + <nav class="flex items-center justify-between px-4 sm:px-0"> + <div class="-mt-px flex w-0 flex-1"> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent pr-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + <svg + class="mr-3 h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M18 10a.75.75 0 01-.75.75H4.66l2.1 1.95a.75.75 0 11-1.02 1.1l-3.5-3.25a.75.75 0 010-1.1l3.5-3.25a.75.75 0 111.02 1.1l-2.1 1.95h12.59A.75.75 0 0118 10z" + clip-rule="evenodd" + /> + </svg> + Previous + </a> + </div> + <div class="hidden md:-mt-px md:flex"> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + 1 + </a> + {/* <!-- Current: "border-indigo-500 text-indigo-600", Default: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300" --> */} + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500" + aria-current="page" + > + 2 + </a> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + 3 + </a> + <span class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500"> + ... + </span> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + 8 + </a> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + 9 + </a> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent px-4 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + 10 + </a> + </div> + <div class="-mt-px flex w-0 flex-1 justify-end"> + <a + href="#" + class="inline-flex items-center border-t-2 border-transparent pl-1 pt-4 text-sm font-medium text-gray-500 hover:border-gray-300 hover:text-gray-700" + > + Next + <svg + class="ml-3 h-5 w-5 text-gray-400" + viewBox="0 0 20 20" + fill="currentColor" + aria-hidden="true" + > + <path + fill-rule="evenodd" + d="M2 10a.75.75 0 01.75-.75h12.59l-2.1-1.95a.75.75 0 111.02-1.1l3.5 3.25a.75.75 0 010 1.1l-3.5 3.25a.75.75 0 11-1.02-1.1l2.1-1.95H2.75A.75.75 0 012 10z" + clip-rule="evenodd" + /> + </svg> + </a> + </div> + </nav> + ); +} diff --git a/packages/exchange-backoffice-ui/src/pages/Info.tsx b/packages/exchange-backoffice-ui/src/pages/Info.tsx deleted file mode 100644 index 661ab02a7..000000000 --- a/packages/exchange-backoffice-ui/src/pages/Info.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { h } from "preact"; - -export function Info() { - return <div>Show key and wire info</div>; -} diff --git a/packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx b/packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx new file mode 100644 index 000000000..9c143addd --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx @@ -0,0 +1,78 @@ +import { VNode, h } from "preact"; +import { allForms } from "./AntiMoneyLaunderingForm.js"; +import { Pages } from "../pages.js"; +import { NiceForm } from "../NiceForm.js"; +import { AmlState } from "../types.js"; +import { Amounts } from "@gnu-taler/taler-util"; + +export function NewFormEntry({ + account, + type, +}: { + account?: string; + type?: string; +}): VNode { + if (!account) { + return <div>no account</div>; + } + if (!type) { + return <SelectForm account={account} />; + } + + const selectedForm = Number.parseInt(type ?? "0", 10); + if (Number.isNaN(selectedForm)) { + return <div>WHAT! {type}</div>; + } + const showingFrom = allForms[selectedForm].impl; + const initial = { + fullName: "loggedIn_user_fullname", + when: { + t_ms: new Date().getTime(), + }, + state: AmlState.pending, + threshold: Amounts.parseOrThrow("USD:10"), + }; + return ( + <NiceForm + initial={initial} + form={showingFrom(initial)} + onSubmit={(v) => { + alert(JSON.stringify(v)); + }} + > + <div class="mt-6 flex items-center justify-end gap-x-6"> + <a + // type="button" + href={Pages.details.url({ account })} + class="text-sm font-semibold leading-6 text-gray-900" + > + Cancel + </a> + <button + type="submit" + class="rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold 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" + > + Confirm + </button> + </div> + </NiceForm> + ); +} + +function SelectForm({ account }: { account: string }) { + return ( + <div> + <pre>New form for account: {account}</pre> + {allForms.map((form, idx) => { + return ( + <a + href={Pages.newFormEntry.url({ account, type: String(idx) })} + class="m-4 block rounded-md w-fit border-0 p-3 py-2 text-center text-sm bg-indigo-700 text-white shadow-sm hover:bg-indigo-600" + > + {form.name} + </a> + ); + })} + </div> + ); +} diff --git a/packages/exchange-backoffice-ui/src/pages/Officer.tsx b/packages/exchange-backoffice-ui/src/pages/Officer.tsx index 4d8b90228..79dd8bace 100644 --- a/packages/exchange-backoffice-ui/src/pages/Officer.tsx +++ b/packages/exchange-backoffice-ui/src/pages/Officer.tsx @@ -4,69 +4,60 @@ import { notifyInfo, useLocalStorage, useMemoryStorage, + useTranslationContext, } from "@gnu-taler/web-util/browser"; import { VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { + Account, UnwrapKeyError, createNewAccount, - createNewSessionId, unlockAccount, } from "../account.js"; import { createNewForm } from "../handlers/forms.js"; +import { Officer, codecForOfficer } from "../Dashboard.js"; export function Officer() { const password = useMemoryStorage("password"); - const session = useLocalStorage("session"); - const officer = useLocalStorage("officer"); - const [keys, setKeys] = useState({ accountId: "", pub: "" }); + const officer = useLocalStorage("officer", { + codec: codecForOfficer(), + }); + const [keys, setKeys] = useState<Account>(); useEffect(() => { - if ( - officer.value === undefined || - session.value === undefined || - password.value === undefined - ) { + if (officer.value === undefined || password.value === undefined) { return; } - unlockAccount(session.value, officer.value, password.value) + + unlockAccount(officer.value.salt, officer.value.key, password.value) .then((keys) => setKeys(keys ?? { accountId: "", pub: "" })) .catch((e) => { if (e instanceof UnwrapKeyError) { console.log(e); } }); - }, [officer.value, session.value, password.value]); - - useEffect(() => { - if (!session.value) { - session.update(createNewSessionId()); - } - }, []); - - const { value: sessionId } = session; - if (!sessionId) { - return <div>loading...</div>; - } + }, [officer.value, password.value]); - if (officer.value === undefined) { + if ( + officer.value === undefined || + !officer.value.key || + !officer.value.salt + ) { return ( <CreateAccount - sessionId={sessionId} - onNewAccount={(id) => { - password.reset(); - officer.update(id); + onNewAccount={(salt, key, pwd) => { + password.update(pwd); + officer.update({ salt, when: { t_ms: Date.now() }, key }); }} /> ); } - console.log("pwd", password.value); if (password.value === undefined) { return ( <UnlockAccount - sessionId={sessionId} - accountId={officer.value} + salt={officer.value.salt} + sealedKey={officer.value.key} onAccountUnlocked={(pwd) => { password.update(pwd); }} @@ -76,42 +67,59 @@ export function Officer() { return ( <div> - <div>Officer</div> - <h1>{sessionId}</h1> <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> Public key </h1> - <div> - <p class="mt-6 leading-8 text-gray-700 break-all"> - -----BEGIN PUBLIC KEY----- - <div>{keys.pub}</div> - -----END PUBLIC KEY----- - </p> - </div> - <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> - Private key - </h1> - <div> - <p class="mt-6 leading-8 text-gray-700 break-all"> - -----BEGIN PRIVATE KEY----- - <div>{keys.accountId}</div> - -----END PRIVATE KEY----- - </p> + <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> </div> + <p> + <a + href={`mailto:aml@exchange.taler.net?body=${encodeURIComponent( + `I want my AML account\n\n\nPubKey: ${keys?.accountId}`, + )}`} + target="_blank" + rel="noreferrer" + 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" + > + Request account activation + </a> + </p> + <p> + <button + type="button" + onClick={() => { + password.reset(); + }} + class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm " + > + Lock account + </button> + </p> + <p> + <button + type="button" + onClick={() => { + officer.reset(); + }} + 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 " + > + Remove account + </button> + </p> </div> ); } function CreateAccount({ - sessionId, onNewAccount, }: { - sessionId: string; - onNewAccount: (accountId: string) => void; + onNewAccount: (salt: string, accountId: string, password: string) => void; }): VNode { + const { i18n } = useTranslationContext(); const Form = createNewForm<{ - email: string; password: string; + repeat: string; }>(); return ( @@ -125,24 +133,50 @@ function CreateAccount({ <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 keys = await createNewAccount(sessionId, v.password); - onNewAccount(keys.accountId); + const keys = await createNewAccount(v.password); + onNewAccount(keys.salt, keys.accountId, v.password); }} > <div class="mb-4"> <Form.InputLine - label={"Email" as TranslatedString} - name="email" - type="email" + 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={"Password" as TranslatedString} - name="password" + label={"Repeat password" as TranslatedString} + name="repeat" type="password" required /> @@ -164,17 +198,15 @@ function CreateAccount({ } function UnlockAccount({ - sessionId, - accountId, + salt, + sealedKey, onAccountUnlocked, }: { - sessionId: string; - accountId: string; + salt: string; + sealedKey: string; onAccountUnlocked: (password: string) => void; }): VNode { const Form = createNewForm<{ - sessionId: string; - accountId: string; password: string; }>(); @@ -182,34 +214,21 @@ function UnlockAccount({ <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"> - Unlock account + 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 - initialValue={{ - sessionId, - accountId: - accountId.substring(0, 6) + - "..." + - accountId.substring(accountId.length - 6), - }} - computeFormState={(v) => { - return { - accountId: { - disabled: true, - }, - sessionId: { - disabled: true, - }, - }; - }} onSubmit={async (v) => { try { // test login - await unlockAccount(sessionId, accountId, v.password); + await unlockAccount(salt, sealedKey, v.password); onAccountUnlocked(v.password ?? ""); notifyInfo("Account unlocked" as TranslatedString); @@ -227,21 +246,6 @@ function UnlockAccount({ > <div class="mb-4"> <Form.InputLine - label={"Session" as TranslatedString} - name="sessionId" - type="text" - /> - </div> - <div class="mb-4"> - <Form.InputLine - label={"AccountId" as TranslatedString} - name="accountId" - type="text" - /> - </div> - - <div class="mb-4"> - <Form.InputLine label={"Password" as TranslatedString} name="password" type="password" |