aboutsummaryrefslogtreecommitdiff
path: root/packages/exchange-backoffice-ui/src/pages
diff options
context:
space:
mode:
authorSebastian <sebasjm@gmail.com>2023-05-25 18:08:20 -0300
committerSebastian <sebasjm@gmail.com>2023-05-26 09:26:09 -0300
commit64e3705669e7c12b8013704654f17cf8eaf659d4 (patch)
treeb0572d228b34740f307da4c59e6e5fa0e3e1f808 /packages/exchange-backoffice-ui/src/pages
parentdad7d48ed2d7cd6f17466889395b49023e4b5097 (diff)
downloadwallet-core-64e3705669e7c12b8013704654f17cf8eaf659d4.tar.xz
cases, account details and new-form screen
Diffstat (limited to 'packages/exchange-backoffice-ui/src/pages')
-rw-r--r--packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx457
-rw-r--r--packages/exchange-backoffice-ui/src/pages/AntiMoneyLaunderingForm.tsx22
-rw-r--r--packages/exchange-backoffice-ui/src/pages/Cases.tsx282
-rw-r--r--packages/exchange-backoffice-ui/src/pages/Info.tsx5
-rw-r--r--packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx78
-rw-r--r--packages/exchange-backoffice-ui/src/pages/Officer.tsx204
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"