aboutsummaryrefslogtreecommitdiff
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
parentdad7d48ed2d7cd6f17466889395b49023e4b5097 (diff)
downloadwallet-core-64e3705669e7c12b8013704654f17cf8eaf659d4.tar.xz
cases, account details and new-form screen
-rw-r--r--packages/exchange-backoffice-ui/src/Dashboard.tsx344
-rw-r--r--packages/exchange-backoffice-ui/src/NiceForm.tsx13
-rw-r--r--packages/exchange-backoffice-ui/src/account.ts48
-rw-r--r--packages/exchange-backoffice-ui/src/assets/logo-2021.svg9
-rw-r--r--packages/exchange-backoffice-ui/src/declaration.d.ts28
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_11e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_12e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_13e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_15e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_1e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_4e.ts5
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_5e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/902_9e.ts11
-rw-r--r--packages/exchange-backoffice-ui/src/forms/simplest.ts96
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx43
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx34
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx86
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputChoiceStacked.tsx18
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/InputLine.tsx6
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/forms.ts32
-rw-r--r--packages/exchange-backoffice-ui/src/handlers/useField.ts31
-rw-r--r--packages/exchange-backoffice-ui/src/index.html2
-rw-r--r--packages/exchange-backoffice-ui/src/pages.ts30
-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
-rw-r--r--packages/exchange-backoffice-ui/src/route.ts4
-rw-r--r--packages/exchange-backoffice-ui/src/types.ts81
-rw-r--r--packages/web-util/src/hooks/useLang.ts4
-rw-r--r--packages/web-util/src/hooks/useLocalStorage.ts64
33 files changed, 1722 insertions, 381 deletions
diff --git a/packages/exchange-backoffice-ui/src/Dashboard.tsx b/packages/exchange-backoffice-ui/src/Dashboard.tsx
index 9be86c533..9f4a43513 100644
--- a/packages/exchange-backoffice-ui/src/Dashboard.tsx
+++ b/packages/exchange-backoffice-ui/src/Dashboard.tsx
@@ -23,39 +23,14 @@ import {
useMemoryStorage,
useNotifications,
} from "@gnu-taler/web-util/browser";
-
-/**
- * references between forms
- *
- * 902.1e
- * --> 902.11 (operational legal entity or partnership)
- * --> 902.12 (a foundation)
- * --> 902.13 (a trust)
- * --> 902.15 (life insurance policy)
- * --> 902.9 (all other cases)
- * --> 902.5 (cash transaction with no customer profile)
- * --> 902.4 (risk profile)
- *
- * 902.11
- * --> 902.9 (beneficial owner in fiduciary holding assets)
- *
- * 902.12
- *
- * 902.13
- *
- * 902.15
- *
- * 902.9
- *
- * 902.5
- *
- * 902.4
- */
-
-const userNavigation = [
- { name: "Your profile", href: "#" },
- { name: "Sign out", href: "#" },
-];
+import {
+ AbsoluteTime,
+ Codec,
+ buildCodecForObject,
+ codecForAbsoluteTime,
+ codecForString,
+} from "@gnu-taler/taler-util";
+import logo from "./assets/logo-2021.svg";
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
@@ -153,7 +128,7 @@ function LeftMenu() {
)}
aria-hidden="true"
/>
- Info
+ Cases
</a>
</li>
<li>
@@ -175,7 +150,7 @@ function LeftMenu() {
)}
aria-hidden="true"
/>
- Officer
+ Account
</a>
</li>
</ul>
@@ -203,7 +178,7 @@ function LeftMenu() {
</li>
</ul>
</li> */}
- <li class="mt-auto">
+ {/* <li class="mt-auto">
<a
href={Pages.settings.url}
class={classNames(
@@ -224,7 +199,7 @@ function LeftMenu() {
/>
Settings
</a>
- </li>
+ </li> */}
</ul>
</nav>
);
@@ -237,26 +212,18 @@ export function Dashboard({
}): VNode {
const [sidebarOpen, setSidebarOpen] = useState(false);
- const logRef = useRef<HTMLPreElement>(null);
- function showFormOnSidebar(v: any) {
- if (!logRef.current) return;
- logRef.current.innerHTML = JSON.stringify(v, undefined, 1);
- }
return (
<Fragment>
<NavigationBar isOpen={sidebarOpen} setOpen={setSidebarOpen}>
<div class="flex grow flex-col gap-y-5 overflow-y-auto bg-indigo-600 px-6 pb-4">
<div class="flex h-16 shrink-0 items-center">
- <img
- class="h-8 w-auto"
- src="https://tailwindui.com/img/logos/mark.svg?color=white"
- alt="Taler"
- />
+ <header class="flex items-center justify-between border-b border-white/5 ">
+ <h1 class="text-base font-semibold leading-7 text-white">
+ Exchange AML Backoffice
+ </h1>
+ </header>
</div>
<LeftMenu />
- <div class="text-white text-sm">
- <pre ref={logRef}></pre>
- </div>
<Footer />
</div>
</NavigationBar>
@@ -362,123 +329,193 @@ function NavigationBar({
);
}
+export interface Officer {
+ salt: string;
+ when: AbsoluteTime;
+ key: string;
+}
+
+export const codecForOfficer = (): Codec<Officer> =>
+ buildCodecForObject<Officer>()
+ .property("salt", codecForString()) // FIXME
+ .property("when", codecForAbsoluteTime) // FIXME
+ .property("key", codecForString())
+ .build("Officer");
+
function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
const password = useMemoryStorage("password");
- const officer = useLocalStorage("officer");
+ const officer = useLocalStorage("officer", {
+ codec: codecForOfficer(),
+ });
return (
- <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
- <button
- type="button"
- class="-m-2.5 p-2.5 text-gray-700 lg:hidden"
- onClick={onOpenSidebar}
- >
- <span class="sr-only">Open sidebar</span>
- <Bars3Icon class="h-6 w-6" aria-hidden="true" />
- </button>
-
- {/* Separator */}
- <div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true" />
-
- <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
- <div class="relative flex flex-1" />
- {/* <form class="relative flex flex-1" action="#" method="GET">
- <label htmlFor="search-field" class="sr-only">
- Search
- </label>
- <MagnifyingGlassIcon
- class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400"
+ <div class="relative flex h-16 justify-between">
+ <div class="relative z-10 flex p-2 lg:hidden">
+ <button
+ type="button"
+ onClick={() => {
+ onOpenSidebar();
+ }}
+ class="inline-flex items-center justify-center rounded-md p-2 text-gray-400 hover:bg-gray-700 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-gray-900"
+ aria-controls="mobile-menu"
+ aria-expanded="false"
+ >
+ <span class="sr-only">Open menu</span>
+ <svg
+ class="block h-6 w-6"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
aria-hidden="true"
- />
- <input
- id="search-field"
- class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm"
- placeholder="Search..."
- type="search"
- name="search"
- />
- </form> */}
- <div class="flex items-center gap-x-4 lg:gap-x-6">
- {/* <button
- type="button"
- class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500"
>
- <span class="sr-only">View notifications</span>
- <BellIcon class="h-6 w-6" aria-hidden="true" />
- </button> */}
-
- {/* Separator */}
- <div
- class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10"
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
+ />
+ </svg>
+ <svg
+ class="hidden h-6 w-6"
+ fill="none"
+ viewBox="0 0 24 24"
+ stroke-width="1.5"
+ stroke="currentColor"
aria-hidden="true"
+ >
+ <path
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ d="M6 18L18 6M6 6l12 12"
+ />
+ </svg>
+ </button>
+ </div>
+ <div class="relative z-0 flex flex-1 items-center justify-center px-2 sm:absolute sm:inset-0">
+ <div class="w-full sm:max-w-xs flex flex-1 items-center justify-center">
+ <img
+ class="h-8 w-auto"
+ src={logo}
+ alt="Taler"
+ style={{ height: 35, margin: 10 }}
/>
-
- {officer.value === undefined ? (
- <div />
- ) : (
- <Menu
- as="div"
- /* @ts-ignore */
- class="relative"
- >
- <Menu.Button class="-m-1.5 flex items-center p-1.5">
- <span class="sr-only">Open user menu</span>
- <img
- class="h-8 w-8 rounded-full bg-gray-50"
- src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
- alt=""
- />
- <span class="hidden lg:flex lg:items-center">
- <span
- class="ml-4 text-sm font-semibold leading-6 text-gray-900"
- aria-hidden="true"
- >
- {/* Tom Cook */}
- {officer.value?.substring(0, 6)}
- </span>
- <ChevronDownIcon
- class="ml-2 h-5 w-5 text-gray-400"
- aria-hidden="true"
- />
- </span>
- </Menu.Button>
- <Transition
- as={Fragment}
- enter="transition ease-out duration-100"
- enterFrom="transform opacity-0 scale-95"
- enterTo="transform opacity-100 scale-100"
- leave="transition ease-in duration-75"
- leaveFrom="transform opacity-100 scale-100"
- leaveTo="transform opacity-0 scale-95"
- >
- <Menu.Items class="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
- <Menu.Item>
- {({ active }: { active: boolean }) => (
- <a
- // href={item.href}
- onClick={() => {
- officer.reset();
- password.reset();
- }}
- class={classNames(
- active ? "bg-gray-50" : "",
- "block px-3 py-1 text-sm leading-6 text-gray-900",
- )}
- >
- Forget account
- </a>
- )}
- </Menu.Item>
- </Menu.Items>
- </Transition>
- </Menu>
- )}
</div>
</div>
+ {/* <div class="relative z-10 flex items-center lg:hidden">dd</div> */}
</div>
);
}
+// return (
+// <div class="sticky top-0 z-40 flex h-16 shrink-0 items-center gap-x-4 border-b border-gray-200 bg-white px-4 shadow-sm sm:gap-x-6 sm:px-6 lg:px-8">
+// <button
+// type="button"
+// class="-m-2.5 p-2.5 text-gray-700 lg:hidden"
+// onClick={onOpenSidebar}
+// >
+// <span class="sr-only">Open sidebar</span>
+// <Bars3Icon class="h-6 w-6" aria-hidden="true" />
+// </button>
+
+// {/* Separator */}
+// <div class="h-6 w-px bg-gray-900/10 lg:hidden" aria-hidden="true" />
+
+// <div class="flex flex-1 gap-x-4 self-stretch lg:gap-x-6">
+// <div class="relative flex flex-1" />
+// {/* <form class="relative flex flex-1" action="#" method="GET">
+// <label htmlFor="search-field" class="sr-only">
+// Search
+// </label>
+// <MagnifyingGlassIcon
+// class="pointer-events-none absolute inset-y-0 left-0 h-full w-5 text-gray-400"
+// aria-hidden="true"
+// />
+// <input
+// id="search-field"
+// class="block h-full w-full border-0 py-0 pl-8 pr-0 text-gray-900 placeholder:text-gray-400 focus:ring-0 sm:text-sm"
+// placeholder="Search..."
+// type="search"
+// name="search"
+// />
+// </form> */}
+// <div class="flex items-center gap-x-4 lg:gap-x-6">
+// {/* <button
+// type="button"
+// class="-m-2.5 p-2.5 text-gray-400 hover:text-gray-500"
+// >
+// <span class="sr-only">View notifications</span>
+// <BellIcon class="h-6 w-6" aria-hidden="true" />
+// </button> */}
+
+// {/* Separator */}
+// <div
+// class="hidden lg:block lg:h-6 lg:w-px lg:bg-gray-900/10"
+// aria-hidden="true"
+// />
+
+// {/* {officerName === undefined ? (
+// <div />
+// ) : (
+// <Menu
+// as="div"
+// class="relative"
+// >
+// <Menu.Button class="-m-1.5 flex items-center p-1.5">
+// <span class="sr-only">Open user menu</span>
+// <img
+// class="h-8 w-8 rounded-full bg-gray-50"
+// src="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
+// alt=""
+// />
+// <span class="hidden lg:flex lg:items-center">
+// <span
+// class="ml-4 text-sm font-semibold leading-6 text-gray-900"
+// aria-hidden="true"
+// >
+// {officerName}
+// </span>
+// <ChevronDownIcon
+// class="ml-2 h-5 w-5 text-gray-400"
+// aria-hidden="true"
+// />
+// </span>
+// </Menu.Button>
+// <Transition
+// as={Fragment}
+// enter="transition ease-out duration-100"
+// enterFrom="transform opacity-0 scale-95"
+// enterTo="transform opacity-100 scale-100"
+// leave="transition ease-in duration-75"
+// leaveFrom="transform opacity-100 scale-100"
+// leaveTo="transform opacity-0 scale-95"
+// >
+// <Menu.Items class="absolute right-0 z-10 mt-2.5 w-48 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
+// <Menu.Item>
+// {({ active }: { active: boolean }) => (
+// <a
+// onClick={() => {
+// officer.reset();
+// password.reset();
+// }}
+// class={classNames(
+// active ? "bg-gray-50" : "",
+// "block px-3 py-1 text-sm leading-6 text-gray-900",
+// )}
+// >
+// Forget account
+// </a>
+// )}
+// </Menu.Item>
+// </Menu.Items>
+// </Transition>
+// </Menu>
+// )} */}
+// </div>
+// </div>
+// </div>
+// );
+// }
+
function Footer() {
return (
<footer class="absolute bottom-4">
@@ -502,7 +539,6 @@ function Notifications() {
{
/* <!-- Global notification live region, render this permanently at the end of the document --> */
}
- console.log("render", ns.length);
return (
<div
aria-live="assertive"
diff --git a/packages/exchange-backoffice-ui/src/NiceForm.tsx b/packages/exchange-backoffice-ui/src/NiceForm.tsx
index 593a373c1..69b977ee0 100644
--- a/packages/exchange-backoffice-ui/src/NiceForm.tsx
+++ b/packages/exchange-backoffice-ui/src/NiceForm.tsx
@@ -1,5 +1,5 @@
import { useTranslationContext } from "@gnu-taler/web-util/browser";
-import { Fragment, h } from "preact";
+import { ComponentChildren, Fragment, h } from "preact";
import { FlexibleForm } from "./forms/index.js";
import { FormProvider } from "./handlers/FormProvider.js";
import { RenderAllFieldsByUiConfig } from "./handlers/forms.js";
@@ -8,21 +8,25 @@ export function NiceForm<T extends object>({
initial,
onUpdate,
form,
+ onSubmit,
+ children,
}: {
+ children?: ComponentChildren;
initial: Partial<T>;
+ onSubmit?: (v: T) => void;
form: FlexibleForm<T>;
- onUpdate: (d: Partial<T>) => void;
+ onUpdate?: (d: Partial<T>) => void;
}) {
- const { i18n } = useTranslationContext();
return (
<FormProvider
initialValue={initial}
onUpdate={onUpdate}
- onSubmit={() => {}}
+ onSubmit={onSubmit}
computeFormState={form.behavior}
>
<div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
{form.design.map((section, i) => {
+ if (!section) return <Fragment />;
return (
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3">
<div class="px-4 sm:px-0">
@@ -49,6 +53,7 @@ export function NiceForm<T extends object>({
);
})}
</div>
+ {children}
</FormProvider>
);
}
diff --git a/packages/exchange-backoffice-ui/src/account.ts b/packages/exchange-backoffice-ui/src/account.ts
index 1e770794a..019c0bb43 100644
--- a/packages/exchange-backoffice-ui/src/account.ts
+++ b/packages/exchange-backoffice-ui/src/account.ts
@@ -7,28 +7,33 @@ import { decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
*
* @returns session id as string
*/
-export function createNewSessionId(): string {
+export function createSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(8));
const iv = crypto.getRandomValues(new Uint8Array(12));
return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer);
}
+export interface Account {
+ accountId: string;
+ secret: CryptoKey;
+}
+
/**
* Restore previous session and unlock account
*
- * @param sessionId string from which crypto params will be derived
- * @param accountId secured private key
+ * @param salt string from which crypto params will be derived
+ * @param key secured private key
* @param password password for the private key
* @returns
*/
export async function unlockAccount(
- sessionId: string,
- accountId: string,
+ salt: string,
+ key: string,
password: string,
-) {
- const key = str2ab(window.atob(accountId));
+): Promise<Account> {
+ const rawKey = str2ab(window.atob(key));
- const privateKey = await recoverWithPassword(key, sessionId, password);
+ const privateKey = await recoverWithPassword(rawKey, salt, password);
const publicKey = await getPublicFromPrivate(privateKey);
@@ -36,9 +41,9 @@ export async function unlockAccount(
throw new Error(String(e));
});
- const pub = btoa(ab2str(pubRaw));
+ const accountId = btoa(ab2str(pubRaw));
- return { accountId, pub };
+ return { accountId, secret: privateKey };
}
/**
@@ -49,12 +54,13 @@ export async function unlockAccount(
* @param password
* @returns
*/
-export async function createNewAccount(sessionId: string, password: string) {
- const { privateKey, publicKey } = await createPair();
+export async function createNewAccount(password: string) {
+ const { privateKey } = await createPair();
+ const salt = createSalt();
const protectedPrivKey = await protectWithPassword(
privateKey,
- sessionId,
+ salt,
password,
);
@@ -64,14 +70,14 @@ export async function createNewAccount(sessionId: string, password: string) {
// throw new Error(String(e));
// });
- const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
- throw new Error(String(e));
- });
+ // const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
+ // throw new Error(String(e));
+ // });
- const pub = btoa(ab2str(pubRaw));
+ // const pub = btoa(ab2str(pubRaw));
const protectedPriv = btoa(ab2str(protectedPrivKey));
- return { accountId: protectedPriv, pub };
+ return { accountId: protectedPriv, salt };
}
const rsaAlgorithm: RsaHashedKeyGenParams = {
@@ -97,7 +103,7 @@ async function protectWithPassword(
sessionId: string,
password: string,
): Promise<ArrayBuffer> {
- const { salt, initVector: iv } = getCryptoPArameters(sessionId);
+ const { salt, initVector: iv } = getCryptoParameters(sessionId);
const passwordAsKey = await crypto.subtle
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
"deriveBits",
@@ -139,7 +145,7 @@ async function recoverWithPassword(
sessionId: string,
password: string,
): Promise<CryptoKey> {
- const { salt, initVector: iv } = getCryptoPArameters(sessionId);
+ const { salt, initVector: iv } = getCryptoParameters(sessionId);
const master = await crypto.subtle
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
@@ -231,7 +237,7 @@ function str2ab(str: string) {
return buf;
}
-function getCryptoPArameters(sessionId: string): {
+function getCryptoParameters(sessionId: string): {
salt: Uint8Array;
initVector: Uint8Array;
} {
diff --git a/packages/exchange-backoffice-ui/src/assets/logo-2021.svg b/packages/exchange-backoffice-ui/src/assets/logo-2021.svg
new file mode 100644
index 000000000..8c5ff3e5b
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/assets/logo-2021.svg
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 201 90">
+ <g fill="#0042b3" fill-rule="evenodd" stroke-width=".3">
+ <path d="M86.7 1.1c15.6 0 29 9.4 36 23.2h-5.9A35.1 35.1 0 0086.7 6.5C67 6.5 51 23.6 51 44.7c0 10.4 3.8 19.7 10 26.6a31.4 31.4 0 01-4.2 3A45.2 45.2 0 0146 44.7c0-24 18.2-43.6 40.7-43.6zm35.8 64.3a40.4 40.4 0 01-39 22.8c3-1.5 6-3.5 8.6-5.7a35.6 35.6 0 0024.6-17.1z" />
+ <path d="M64.2 1.1l3.1.1c-3 1.6-5.9 3.5-8.5 5.8a37.5 37.5 0 00-30.2 37.7c0 14.3 7.3 26.7 18 33.3a29.6 29.6 0 01-8.5.2c-9-8-14.6-20-14.6-33.5 0-24 18.2-43.6 40.7-43.6zm5.4 81.4a35.6 35.6 0 0024.6-17.1h5.9a40.4 40.4 0 01-39 22.8c3-1.5 5.9-3.5 8.5-5.7zm24.8-58.2a37 37 0 00-12.6-12.8 29.6 29.6 0 018.5-.2c4 3.6 7.4 8 9.9 13z" />
+ <path d="M41.8 1.1c1 0 2 0 3.1.2-3 1.5-5.9 3.4-8.5 5.6A37.5 37.5 0 006.1 44.7c0 21.1 16 38.3 35.7 38.3 12.6 0 23.6-7 30-17.6h5.8a40.4 40.4 0 01-35.8 23C19.3 88.4 1 68.8 1 44.7c0-24 18.2-43.6 40.7-43.6zm30.1 23.2a38.1 38.1 0 00-4.5-6.1c1.3-1.2 2.7-2.2 4.3-3 2.3 2.7 4.4 5.8 6 9.1z" />
+ </g>
+ <path d="M76.1 34.4h9.2v-5H61.9v5H71v26h5.1zM92.6 52.9h13.7l3 7.4h5.3l-12.7-31.2h-4.7L84.5 60.3h5.2zm11.8-4.9h-9.9l5-12.4zM123.8 29.4h-4.6v31h20.6v-5h-16zM166.5 29.4H145v31h21.6v-5H150v-8.3h14.5v-4.9h-14.5v-8h16.4zM191.2 39.5c0 1.6-.5 2.8-1.6 3.8s-2.6 1.4-4.4 1.4h-7.4V34.3h7.4c1.9 0 3.4.4 4.4 1.3 1 .9 1.6 2.2 1.6 3.9zm6 20.8l-7.7-11.7c1-.3 1.9-.7 2.7-1.3a8.8 8.8 0 003.6-4.6c.4-1 .5-2.2.5-3.5 0-1.5-.2-2.9-.7-4.1a8.4 8.4 0 00-2.1-3.1c-1-.8-2-1.5-3.4-2-1.3-.4-2.8-.6-4.5-.6h-12.9v31h5V49.4h6.5l7 10.8z" />
+</svg> \ No newline at end of file
diff --git a/packages/exchange-backoffice-ui/src/declaration.d.ts b/packages/exchange-backoffice-ui/src/declaration.d.ts
index c1e9addbc..11a10860d 100644
--- a/packages/exchange-backoffice-ui/src/declaration.d.ts
+++ b/packages/exchange-backoffice-ui/src/declaration.d.ts
@@ -1,2 +1,30 @@
declare const __VERSION__: string;
declare const __GIT_HASH__: string;
+
+declare module "*.po" {
+ const content: any;
+ export default content;
+}
+declare module "jed" {
+ const x: any;
+ export = x;
+}
+declare module "*.jpeg" {
+ const content: any;
+ export default content;
+}
+declare module "*.png" {
+ const content: any;
+ export default content;
+}
+declare module "*.svg" {
+ const content: any;
+ export default content;
+}
+
+declare module "*.scss" {
+ const content: Record<string, string>;
+ export default content;
+}
+declare const __VERSION__: string;
+declare const __GIT_HASH__: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/902_11e.ts b/packages/exchange-backoffice-ui/src/forms/902_11e.ts
index 0e9a28dce..267b5b52d 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_11e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_11e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_11e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_11.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -115,8 +116,8 @@ export const v1: FlexibleForm<Form902_11e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_11e.Form>,
- ): FormState<Form902_11e.Form> {
+ v: Partial<Form902_11.Form>,
+ ): FormState<Form902_11.Form> {
return {
person: {
hidden:
@@ -128,9 +129,9 @@ export const v1: FlexibleForm<Form902_11e.Form> = {
},
};
},
-};
+});
-namespace Form902_11e {
+namespace Form902_11 {
interface Person {
lastName: string;
firstName: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/902_12e.ts b/packages/exchange-backoffice-ui/src/forms/902_12e.ts
index e58850660..56a3986ee 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_12e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_12e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_12e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_12.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -364,8 +365,8 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_12e.Form>,
- ): FormState<Form902_12e.Form> {
+ v: Partial<Form902_12.Form>,
+ ): FormState<Form902_12.Form> {
return {
founders: {
elements: (v.founders ?? []).map((f) => {
@@ -390,9 +391,9 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
},
};
},
-};
+});
-namespace Form902_12e {
+namespace Form902_12 {
interface Foundation {
name: string;
type: "discretionary" | "non-discretionary";
diff --git a/packages/exchange-backoffice-ui/src/forms/902_13e.ts b/packages/exchange-backoffice-ui/src/forms/902_13e.ts
index bca96e842..e933432e4 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_13e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_13e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_13e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_13.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -441,8 +442,8 @@ export const v1: FlexibleForm<Form902_13e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_13e.Form>,
- ): FormState<Form902_13e.Form> {
+ v: Partial<Form902_13.Form>,
+ ): FormState<Form902_13.Form> {
return {
settlors: {
elements: (v.settlors ?? []).map((f) => {
@@ -476,9 +477,9 @@ export const v1: FlexibleForm<Form902_13e.Form> = {
},
};
},
-};
+});
-namespace Form902_13e {
+namespace Form902_13 {
interface Foundation {
name: string;
type: "discretionary" | "non-discretionary";
diff --git a/packages/exchange-backoffice-ui/src/forms/902_15e.ts b/packages/exchange-backoffice-ui/src/forms/902_15e.ts
index 8e3fa1350..be304f357 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_15e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_15e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_15e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_15.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -160,17 +161,17 @@ export const v1: FlexibleForm<Form902_15e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_15e.Form>,
- ): FormState<Form902_15e.Form> {
+ v: Partial<Form902_15.Form>,
+ ): FormState<Form902_15.Form> {
return {
when: {
disabled: true,
},
};
},
-};
+});
-namespace Form902_15e {
+namespace Form902_15 {
interface Person {
fullName: string;
address: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/902_1e.ts b/packages/exchange-backoffice-ui/src/forms/902_1e.ts
index cd65cfedc..0f60c23d8 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_1e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_1e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FlexibleForm, languageList } from "./index.js";
import { FormState } from "../handlers/FormProvider.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_1e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -512,8 +513,8 @@ export const v1: FlexibleForm<Form902_1e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_1e.Form>,
- ): FormState<Form902_1e.Form> {
+ v: Partial<Form902_1.Form>,
+ ): FormState<Form902_1.Form> {
return {
fullName: {
disabled: true,
@@ -606,9 +607,9 @@ export const v1: FlexibleForm<Form902_1e.Form> = {
},
};
},
-};
+});
-namespace Form902_1e {
+namespace Form902_1 {
interface LegalEntityCustomer {
companyName: string;
domicile: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/902_4e.ts b/packages/exchange-backoffice-ui/src/forms/902_4e.ts
index ca7ef8505..ffe3b28a2 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_4e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_4e.ts
@@ -4,8 +4,9 @@ import { FlexibleForm } from "./index.js";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import { h as create } from "preact";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_4.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -745,7 +746,7 @@ export const v1: FlexibleForm<Form902_4.Form> = {
},
};
},
-};
+});
namespace Form902_4 {
export interface Form {
diff --git a/packages/exchange-backoffice-ui/src/forms/902_5e.ts b/packages/exchange-backoffice-ui/src/forms/902_5e.ts
index 60bd551d5..8ff72e0c5 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_5e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_5e.ts
@@ -5,8 +5,9 @@ import {
} from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm, currencyList } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_12e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_5.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -230,8 +231,8 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_12e.Form>,
- ): FormState<Form902_12e.Form> {
+ v: Partial<Form902_5.Form>,
+ ): FormState<Form902_5.Form> {
return {
when: {
disabled: true,
@@ -243,9 +244,9 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
},
};
},
-};
+});
-namespace Form902_12e {
+namespace Form902_5 {
export interface Form {
customer: string;
fullName: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/902_9e.ts b/packages/exchange-backoffice-ui/src/forms/902_9e.ts
index 6d88f8578..831fcc9f9 100644
--- a/packages/exchange-backoffice-ui/src/forms/902_9e.ts
+++ b/packages/exchange-backoffice-ui/src/forms/902_9e.ts
@@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
-export const v1: FlexibleForm<Form902_9e.Form> = {
+export const v1 = (current: State): FlexibleForm<Form902_9.Form> => ({
versionId: "2023-05-15",
design: [
{
@@ -104,17 +105,17 @@ export const v1: FlexibleForm<Form902_9e.Form> = {
},
],
behavior: function formBehavior(
- v: Partial<Form902_9e.Form>,
- ): FormState<Form902_9e.Form> {
+ v: Partial<Form902_9.Form>,
+ ): FormState<Form902_9.Form> {
return {
when: {
disabled: true,
},
};
},
-};
+});
-namespace Form902_9e {
+namespace Form902_9 {
interface Person {
surname: string;
firstName: string;
diff --git a/packages/exchange-backoffice-ui/src/forms/simplest.ts b/packages/exchange-backoffice-ui/src/forms/simplest.ts
new file mode 100644
index 000000000..a395410c3
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/forms/simplest.ts
@@ -0,0 +1,96 @@
+import {
+ AbsoluteTime,
+ AmountJson,
+ Amounts,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
+import { FormState } from "../handlers/FormProvider.js";
+import { FlexibleForm } from "./index.js";
+import { AmlState } from "../types.js";
+import { amlStateConverter } from "../pages/AccountDetails.js";
+import { State } from "../pages/AntiMoneyLaunderingForm.js";
+
+export const v1 = (current: State): FlexibleForm<Simplest.Form> => ({
+ versionId: "2023-05-25",
+ design: [
+ {
+ title: "Simple form" as TranslatedString,
+ fields: [
+ {
+ type: "textArea",
+ props: {
+ name: "comment",
+ label: "Comments" as TranslatedString,
+ },
+ },
+ ],
+ },
+ {
+ title: "Resolution" as TranslatedString,
+ description: `Current state is ${amlStateConverter.toStringUI(
+ current.state,
+ )} and threshold at ${Amounts.stringifyValue(
+ current.threshold,
+ )}` as TranslatedString,
+ fields: [
+ {
+ type: "date",
+ props: {
+ name: "when",
+ label: "Decision Time" as TranslatedString,
+ },
+ },
+ {
+ type: "choiceHorizontal",
+ props: {
+ name: "state",
+ label: "New state" as TranslatedString,
+ converter: amlStateConverter,
+ choices: [
+ {
+ value: AmlState.frozen,
+ label: "Frozen" as TranslatedString,
+ },
+ {
+ value: AmlState.pending,
+ label: "Pending" as TranslatedString,
+ },
+ {
+ value: AmlState.normal,
+ label: "Normal" as TranslatedString,
+ },
+ ],
+ },
+ },
+ {
+ type: "amount",
+ props: {
+ name: "threshold",
+ label: "New threshold" as TranslatedString,
+ },
+ },
+ ],
+ },
+ ],
+ behavior: function formBehavior(
+ v: Partial<Simplest.Form>,
+ ): FormState<Simplest.Form> {
+ return {
+ when: {
+ disabled: true,
+ },
+ threshold: {
+ disabled: v.state === AmlState.frozen,
+ },
+ };
+ },
+});
+
+namespace Simplest {
+ export interface Form {
+ when: AbsoluteTime;
+ threshold: AmountJson;
+ state: AmlState;
+ comment: string;
+ }
+}
diff --git a/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx b/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
index 87c4c43fb..4ac90ad57 100644
--- a/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/FormProvider.tsx
@@ -1,6 +1,16 @@
-import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
+import {
+ AbsoluteTime,
+ AmountJson,
+ TranslatedString,
+} from "@gnu-taler/taler-util";
import { ComponentChildren, VNode, createContext, h } from "preact";
-import { MutableRef, StateUpdater, useEffect, useRef } from "preact/hooks";
+import {
+ MutableRef,
+ StateUpdater,
+ useEffect,
+ useRef,
+ useState,
+} from "preact/hooks";
export interface FormType<T> {
value: MutableRef<Partial<T>>;
@@ -15,6 +25,8 @@ export const FormContext = createContext<FormType<any>>({});
export type FormState<T> = {
[field in keyof T]?: T[field] extends AbsoluteTime
? Partial<InputFieldState>
+ : T[field] extends AmountJson
+ ? Partial<InputFieldState>
: T[field] extends Array<infer P>
? Partial<InputArrayFieldState<P>>
: T[field] extends object
@@ -40,22 +52,31 @@ export interface InputArrayFieldState<T> extends InputFieldState {
export function FormProvider<T>({
children,
initialValue,
- onUpdate,
+ onUpdate: notify,
onSubmit,
computeFormState,
}: {
initialValue?: Partial<T>;
onUpdate?: (v: Partial<T>) => void;
- onSubmit: (v: T) => void;
+ onSubmit?: (v: T) => void;
computeFormState?: (v: T) => FormState<T>;
children: ComponentChildren;
}): VNode {
- const value = useRef(initialValue ?? {});
- useEffect(() => {
- return function onUnload() {
- value.current = initialValue ?? {};
- };
- });
+ // const value = useRef(initialValue ?? {});
+ // useEffect(() => {
+ // return function onUnload() {
+ // value.current = initialValue ?? {};
+ // };
+ // });
+ // const onUpdate = notify
+ const [state, setState] = useState<Partial<T>>(initialValue ?? {});
+ const value = { current: state };
+ // console.log("RENDER", initialValue, value);
+ const onUpdate = (v: typeof state) => {
+ // console.log("updated");
+ setState(v);
+ if (notify) notify(v);
+ };
return (
<FormContext.Provider
value={{ initialValue, value, onUpdate, computeFormState }}
@@ -64,7 +85,7 @@ export function FormProvider<T>({
onSubmit={(e) => {
e.preventDefault();
//@ts-ignore
- onSubmit(value.current);
+ if (onSubmit) onSubmit(value.current);
}}
>
{children}
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx b/packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx
new file mode 100644
index 000000000..9be9dd4d0
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx
@@ -0,0 +1,34 @@
+import { AmountJson, Amounts, TranslatedString } from "@gnu-taler/taler-util";
+import { VNode, h } from "preact";
+import { InputLine, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export function InputAmount<T extends object, K extends keyof T>(
+ props: { currency?: string } & UIFormProps<T, K>,
+): VNode {
+ const { value } = useField<T, K>(props.name);
+ const currency =
+ !value || !(value as any).currency
+ ? props.currency
+ : (value as any).currency;
+ return (
+ <InputLine<T, K>
+ type="text"
+ before={{
+ type: "text",
+ text: currency as TranslatedString,
+ }}
+ converter={{
+ //@ts-ignore
+ fromStringUI: (v): AmountJson => {
+ return Amounts.parseOrThrow(`${currency}:${v}`);
+ },
+ //@ts-ignore
+ toStringUI: (v: AmountJson) => {
+ return v === undefined ? "" : Amounts.stringifyValue(v);
+ },
+ }}
+ {...props}
+ />
+ );
+}
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx b/packages/exchange-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx
new file mode 100644
index 000000000..fdee35447
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/handlers/InputChoiceHorizontal.tsx
@@ -0,0 +1,86 @@
+import { TranslatedString } from "@gnu-taler/taler-util";
+import { Fragment, VNode, h } from "preact";
+import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
+import { useField } from "./useField.js";
+
+export interface Choice<V> {
+ label: TranslatedString;
+ value: V;
+}
+
+export function InputChoiceHorizontal<T extends object, K extends keyof T>(
+ props: {
+ choices: Choice<T[K]>[];
+ } & UIFormProps<T, K>,
+): VNode {
+ const {
+ choices,
+ name,
+ label,
+ tooltip,
+ help,
+ placeholder,
+ required,
+ before,
+ after,
+ converter,
+ } = props;
+ const { value, onChange, state, isDirty } = useField<T, K>(name);
+ if (state.hidden) {
+ return <Fragment />;
+ }
+
+ return (
+ <div class="sm:col-span-6">
+ <LabelWithTooltipMaybeRequired
+ label={label}
+ required={required}
+ tooltip={tooltip}
+ />
+ <fieldset class="mt-2">
+ <div class="isolate inline-flex rounded-md shadow-sm">
+ {choices.map((choice, idx) => {
+ const isFirst = idx === 0;
+ const isLast = idx === choices.length - 1;
+ let clazz =
+ "relative inline-flex items-center px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 focus:z-10";
+ if (choice.value === value) {
+ clazz +=
+ " text-white bg-indigo-600 hover:bg-indigo-500 ring-2 ring-indigo-600 hover:ring-indigo-500";
+ } else {
+ clazz += " hover:bg-gray-100 border-gray-300";
+ }
+ if (isFirst) {
+ clazz += " rounded-l-md";
+ } else {
+ clazz += " -ml-px";
+ }
+ if (isLast) {
+ clazz += " rounded-r-md";
+ }
+ return (
+ <button
+ type="button"
+ class={clazz}
+ onClick={(e) => {
+ onChange(
+ (value === choice.value ? undefined : choice.value) as T[K],
+ );
+ }}
+ >
+ {(!converter
+ ? (choice.value as string)
+ : converter?.toStringUI(choice.value)) ?? ""}
+ </button>
+ );
+ })}
+ </div>
+ </fieldset>
+ {help && (
+ <p class="mt-2 text-sm text-gray-500" id="email-description">
+ {help}
+ </p>
+ )}
+ </div>
+ );
+}
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputChoiceStacked.tsx b/packages/exchange-backoffice-ui/src/handlers/InputChoiceStacked.tsx
index 3bce0123f..c37984368 100644
--- a/packages/exchange-backoffice-ui/src/handlers/InputChoiceStacked.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/InputChoiceStacked.tsx
@@ -3,15 +3,15 @@ import { Fragment, VNode, h } from "preact";
import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
import { useField } from "./useField.js";
-export interface Choice {
+export interface Choice<V> {
label: TranslatedString;
description?: TranslatedString;
- value: string;
+ value: V;
}
export function InputChoiceStacked<T extends object, K extends keyof T>(
props: {
- choices: Choice[];
+ choices: Choice<T[K]>[];
} & UIFormProps<T, K>,
): VNode {
const {
@@ -41,6 +41,10 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
<fieldset class="mt-2">
<div class="space-y-4">
{choices.map((choice) => {
+ // const currentValue = !converter
+ // ? choice.value
+ // : converter.fromStringUI(choice.value) ?? "";
+
let clazz =
"border relative block cursor-pointer rounded-lg bg-white px-6 py-4 shadow-sm focus:outline-none sm:flex sm:justify-between";
if (choice.value === value) {
@@ -49,12 +53,18 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
} else {
clazz += " border-gray-300";
}
+
return (
<label class={clazz}>
<input
type="radio"
name="server-size"
- defaultValue={choice.value}
+ // defaultValue={choice.value}
+ value={
+ (!converter
+ ? (choice.value as string)
+ : converter?.toStringUI(choice.value)) ?? ""
+ }
onClick={(e) => {
onChange(
(value === choice.value
diff --git a/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx b/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
index 8e847a273..9448ef5e4 100644
--- a/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
+++ b/packages/exchange-backoffice-ui/src/handlers/InputLine.tsx
@@ -250,7 +250,8 @@ export function InputLine<T extends object, K extends keyof T>(
onChange(fromString(e.currentTarget.value));
}}
placeholder={placeholder ? placeholder : undefined}
- defaultValue={toString(value)}
+ value={toString(value) ?? ""}
+ // defaultValue={toString(value)}
disabled={state.disabled}
aria-invalid={showError}
// aria-describedby="email-error"
@@ -269,7 +270,8 @@ export function InputLine<T extends object, K extends keyof T>(
onChange(fromString(e.currentTarget.value));
}}
placeholder={placeholder ? placeholder : undefined}
- defaultValue={toString(value)}
+ value={toString(value) ?? ""}
+ // defaultValue={toString(value)}
disabled={state.disabled}
aria-invalid={showError}
// aria-describedby="email-error"
diff --git a/packages/exchange-backoffice-ui/src/handlers/forms.ts b/packages/exchange-backoffice-ui/src/handlers/forms.ts
index 115127cc3..4eb188a09 100644
--- a/packages/exchange-backoffice-ui/src/handlers/forms.ts
+++ b/packages/exchange-backoffice-ui/src/handlers/forms.ts
@@ -13,8 +13,10 @@ import { Group } from "./Group.js";
import { InputSelectOne } from "./InputSelectOne.js";
import { FormProvider } from "./FormProvider.js";
import { InputLine } from "./InputLine.js";
+import { InputAmount } from "./InputAmount.js";
+import { InputChoiceHorizontal } from "./InputChoiceHorizontal.js";
-export type DoubleColumnForm = DoubleColumnFormSection[];
+export type DoubleColumnForm = Array<DoubleColumnFormSection | undefined>;
type DoubleColumnFormSection = {
title: TranslatedString;
@@ -35,8 +37,10 @@ type FieldType<T extends object = any, K extends keyof T = any> = {
text: Parameters<typeof InputText<T, K>>[0];
textArea: Parameters<typeof InputTextArea<T, K>>[0];
choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
+ choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
date: Parameters<typeof InputDate<T, K>>[0];
integer: Parameters<typeof InputInteger<T, K>>[0];
+ amount: Parameters<typeof InputAmount<T, K>>[0];
};
/**
@@ -47,11 +51,13 @@ export type UIFormField =
| { type: "caption"; props: FieldType["caption"] }
| { type: "array"; props: FieldType["array"] }
| { type: "file"; props: FieldType["file"] }
+ | { type: "amount"; props: FieldType["amount"] }
| { type: "selectOne"; props: FieldType["selectOne"] }
| { type: "selectMultiple"; props: FieldType["selectMultiple"] }
| { type: "text"; props: FieldType["text"] }
| { type: "textArea"; props: FieldType["textArea"] }
| { type: "choiceStacked"; props: FieldType["choiceStacked"] }
+ | { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
| { type: "integer"; props: FieldType["integer"] }
| { type: "date"; props: FieldType["date"] };
@@ -79,11 +85,15 @@ const UIFormConfiguration: UIFormFieldMap = {
date: InputDate,
//@ts-ignore
choiceStacked: InputChoiceStacked,
+ //@ts-ignore
+ choiceHorizontal: InputChoiceHorizontal,
integer: InputInteger,
//@ts-ignore
selectOne: InputSelectOne,
//@ts-ignore
selectMultiple: InputSelectMultiple,
+ //@ts-ignore
+ amount: InputAmount,
};
export function RenderAllFieldsByUiConfig({
@@ -103,13 +113,23 @@ export function RenderAllFieldsByUiConfig({
);
}
-type FormSet<T extends object, K extends keyof T = any> = {
+type FormSet<T extends object> = {
Provider: typeof FormProvider<T>;
- InputLine: typeof InputLine<T, K>;
+ InputLine: <K extends keyof T>() => typeof InputLine<T, K>;
+ InputChoiceHorizontal: <K extends keyof T>() => typeof InputChoiceHorizontal<
+ T,
+ K
+ >;
};
-export function createNewForm<T extends object>(): FormSet<T> {
- return {
+export function createNewForm<T extends object>() {
+ const res: FormSet<T> = {
Provider: FormProvider,
- InputLine: InputLine,
+ InputLine: () => InputLine,
+ InputChoiceHorizontal: () => InputChoiceHorizontal,
+ };
+ return {
+ Provider: res.Provider,
+ InputLine: res.InputLine(),
+ InputChoiceHorizontal: res.InputChoiceHorizontal(),
};
}
diff --git a/packages/exchange-backoffice-ui/src/handlers/useField.ts b/packages/exchange-backoffice-ui/src/handlers/useField.ts
index 60e65f435..bf94d2f5d 100644
--- a/packages/exchange-backoffice-ui/src/handlers/useField.ts
+++ b/packages/exchange-backoffice-ui/src/handlers/useField.ts
@@ -1,9 +1,5 @@
-import { TargetedEvent, useContext, useState } from "preact/compat";
-import {
- FormContext,
- InputArrayFieldState,
- InputFieldState,
-} from "./FormProvider.js";
+import { useContext, useState } from "preact/compat";
+import { FormContext, InputFieldState } from "./FormProvider.js";
export interface InputFieldHandler<Type> {
value: Type;
@@ -21,11 +17,13 @@ export function useField<T extends object, K extends keyof T>(
computeFormState,
onUpdate: notifyUpdate,
} = useContext(FormContext);
+
type P = typeof name;
type V = T[P];
const formState = computeFormState ? computeFormState(formValue.current) : {};
const fieldValue = readField(formValue.current, String(name)) as V;
+ // console.log("USE FIELD", String(name), formValue.current, fieldValue);
const [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue);
const fieldState =
readField<Partial<InputFieldState>>(formState, String(name)) ?? {};
@@ -66,10 +64,23 @@ export function useField<T extends object, K extends keyof T>(
* @param name
* @returns
*/
-function readField<T>(object: any, name: string): T | undefined {
- return name
- .split(".")
- .reduce((prev, current) => prev && prev[current], object);
+function readField<T>(
+ object: any,
+ name: string,
+ debug?: boolean,
+): T | undefined {
+ return name.split(".").reduce((prev, current) => {
+ if (debug) {
+ console.log(
+ "READ",
+ name,
+ prev,
+ current,
+ prev ? prev[current] : undefined,
+ );
+ }
+ return prev ? prev[current] : undefined;
+ }, object);
}
function setValueDeeper(object: any, names: string[], value: any): any {
diff --git a/packages/exchange-backoffice-ui/src/index.html b/packages/exchange-backoffice-ui/src/index.html
index 3cf38851f..703d31da1 100644
--- a/packages/exchange-backoffice-ui/src/index.html
+++ b/packages/exchange-backoffice-ui/src/index.html
@@ -30,6 +30,8 @@
/>
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<title>Exchange Backoffice</title>
+ <!-- Optional customization script. -->
+ <script src="exchange-backofice-ui-settings.js"></script>
<!-- Entry point for the SPA. -->
<script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" />
diff --git a/packages/exchange-backoffice-ui/src/pages.ts b/packages/exchange-backoffice-ui/src/pages.ts
index a78a137a0..2b13ce585 100644
--- a/packages/exchange-backoffice-ui/src/pages.ts
+++ b/packages/exchange-backoffice-ui/src/pages.ts
@@ -4,15 +4,26 @@ import { AntiMoneyLaunderingForm } from "./pages/AntiMoneyLaunderingForm.js";
import { Welcome } from "./pages/Welcome.js";
import { PageEntry, pageDefinition } from "./route.js";
import { Officer } from "./pages/Officer.js";
-import { Info } from "./pages/Info.js";
+import { Cases } from "./pages/Cases.js";
+import { AccountDetails } from "./pages/AccountDetails.js";
+import { NewFormEntry } from "./pages/NewFormEntry.js";
const home: PageEntry = {
url: "#/",
view: Home,
};
-const info: PageEntry = {
- url: "#/info",
- view: Info,
+const cases: PageEntry = {
+ url: "#/cases",
+ view: Cases,
+};
+const account: PageEntry<{ account?: string }> = {
+ url: pageDefinition("#/account/:account"),
+ view: AccountDetails,
+};
+
+const newFormEntry: PageEntry<{ account?: string; type?: string }> = {
+ url: pageDefinition("#/account/:account/new/:type?"),
+ view: NewFormEntry,
};
const settings: PageEntry = {
@@ -32,4 +43,13 @@ const form: PageEntry<{ number?: string }> = {
view: AntiMoneyLaunderingForm,
};
-export const Pages = { home, info, officer, settings, welcome, form };
+export const Pages = {
+ home,
+ info: cases,
+ officer,
+ details: account,
+ settings,
+ welcome,
+ form,
+ newFormEntry,
+};
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"
diff --git a/packages/exchange-backoffice-ui/src/route.ts b/packages/exchange-backoffice-ui/src/route.ts
index ed6d8058d..d54f9be83 100644
--- a/packages/exchange-backoffice-ui/src/route.ts
+++ b/packages/exchange-backoffice-ui/src/route.ts
@@ -1,5 +1,5 @@
import { createHashHistory } from "history";
-import { VNode } from "preact";
+import { h as create, VNode } from "preact";
import { useEffect, useState } from "preact/hooks";
const history = createHashHistory();
@@ -64,7 +64,7 @@ export function Router({
}): VNode {
const current = useCurrentLocation(pageList);
if (current !== undefined) {
- return current.page.view(current.values ?? {});
+ return create(current.page.view, current.values);
}
return onNotFound();
}
diff --git a/packages/exchange-backoffice-ui/src/types.ts b/packages/exchange-backoffice-ui/src/types.ts
new file mode 100644
index 000000000..1197b6b35
--- /dev/null
+++ b/packages/exchange-backoffice-ui/src/types.ts
@@ -0,0 +1,81 @@
+export interface AmlDecisionDetails {
+ // Array of AML decisions made for this account. Possibly
+ // contains only the most recent decision if "history" was
+ // not set to 'true'.
+ aml_history: AmlDecisionDetail[];
+
+ // Array of KYC attributes obtained for this account.
+ kyc_attributes: KycDetail[];
+}
+
+type AmlOfficerPublicKeyP = string;
+
+export interface AmlDecisionDetail {
+ // What was the justification given?
+ justification: string;
+
+ // What is the new AML state.
+ new_state: Integer;
+
+ // When was this decision made?
+ decision_time: Timestamp;
+
+ // What is the new AML decision threshold (in monthly transaction volume)?
+ new_threshold: Amount;
+
+ // Who made the decision?
+ decider_pub: AmlOfficerPublicKeyP;
+}
+export interface KycDetail {
+ // Name of the configuration section that specifies the provider
+ // which was used to collect the KYC details
+ provider_section: string;
+
+ // The collected KYC data. NULL if the attribute data could not
+ // be decrypted (internal error of the exchange, likely the
+ // attribute key was changed).
+ attributes?: Object;
+
+ // Time when the KYC data was collected
+ collection_time: Timestamp;
+
+ // Time when the validity of the KYC data will expire
+ expiration_time: Timestamp;
+}
+
+interface Timestamp {
+ // Seconds since epoch, or the special
+ // value "never" to represent an event that will
+ // never happen.
+ t_s: number | "never";
+}
+
+type PaytoHash = string;
+type Integer = number;
+type Amount = string;
+
+export interface AmlRecords {
+ // Array of AML records matching the query.
+ records: AmlRecord[];
+}
+
+interface AmlRecord {
+ // Which payto-address is this record about.
+ // Identifies a GNU Taler wallet or an affected bank account.
+ h_payto: PaytoHash;
+
+ // What is the current AML state.
+ current_state: AmlState;
+
+ // Monthly transaction threshold before a review will be triggered
+ threshold: Amount;
+
+ // RowID of the record.
+ rowid: Integer;
+}
+
+export enum AmlState {
+ normal = 0,
+ pending = 1,
+ frozen = 2,
+}
diff --git a/packages/web-util/src/hooks/useLang.ts b/packages/web-util/src/hooks/useLang.ts
index 9888cc51a..d64cf6e1a 100644
--- a/packages/web-util/src/hooks/useLang.ts
+++ b/packages/web-util/src/hooks/useLang.ts
@@ -24,6 +24,6 @@ function getBrowserLang(): string | undefined {
}
export function useLang(initial?: string): Required<LocalStorageState> {
- const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2);
- return useLocalStorage("lang-preference", defaultLang);
+ const defaultValue = (getBrowserLang() || initial || "en").substring(0, 2);
+ return useLocalStorage("lang-preference", { defaultValue: defaultValue });
}
diff --git a/packages/web-util/src/hooks/useLocalStorage.ts b/packages/web-util/src/hooks/useLocalStorage.ts
index 131825736..55efd01cb 100644
--- a/packages/web-util/src/hooks/useLocalStorage.ts
+++ b/packages/web-util/src/hooks/useLocalStorage.ts
@@ -19,6 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm)
*/
+import { Codec } from "@gnu-taler/taler-util";
import { useEffect, useState } from "preact/hooks";
import {
ObservableMap,
@@ -27,9 +28,9 @@ import {
memoryMap,
} from "../utils/observable.js";
-export interface LocalStorageState {
- value?: string;
- update: (s: string) => void;
+export interface LocalStorageState<Type = string> {
+ value?: Type;
+ update: (s: Type) => void;
reset: () => void;
}
@@ -47,33 +48,62 @@ const storage: ObservableMap<string, string> = (function buildStorage() {
}
})();
-export function useLocalStorage(
+//with initial value
+export function useLocalStorage<Type = string>(
key: string,
- initialValue: string,
-): Required<LocalStorageState>;
-export function useLocalStorage(key: string): LocalStorageState;
-export function useLocalStorage(
+ options?: {
+ defaultValue: Type;
+ codec?: Codec<Type>;
+ },
+): Required<LocalStorageState<Type>>;
+//without initial value
+export function useLocalStorage<Type = string>(
key: string,
- initialValue?: string,
-): LocalStorageState {
- const [storedValue, setStoredValue] = useState<string | undefined>(
- (): string | undefined => {
- return storage.get(key) ?? initialValue;
+ options?: {
+ codec?: Codec<Type>;
+ },
+): LocalStorageState<Type>;
+// impl
+export function useLocalStorage<Type = string>(
+ key: string,
+ options?: {
+ defaultValue?: Type;
+ codec?: Codec<Type>;
+ },
+): LocalStorageState<Type> {
+ function convert(updated: string | undefined): Type | undefined {
+ if (updated === undefined) return options?.defaultValue; //optional
+ try {
+ return !options?.codec
+ ? (updated as Type)
+ : options.codec.decode(JSON.parse(updated));
+ } catch (e) {
+ //decode error
+ return options?.defaultValue;
+ }
+ }
+ const [storedValue, setStoredValue] = useState<Type | undefined>(
+ (): Type | undefined => {
+ const prev = storage.get(key);
+ return convert(prev);
},
);
useEffect(() => {
return storage.onUpdate(key, () => {
const newValue = storage.get(key);
- setStoredValue(newValue ?? initialValue);
+ setStoredValue(convert(newValue));
});
}, []);
- const setValue = (value?: string): void => {
+ const setValue = (value?: Type): void => {
if (value === undefined) {
storage.delete(key);
} else {
- storage.set(key, value);
+ storage.set(
+ key,
+ options?.codec ? JSON.stringify(value) : (value as string),
+ );
}
};
@@ -81,7 +111,7 @@ export function useLocalStorage(
value: storedValue,
update: setValue,
reset: () => {
- setValue(initialValue);
+ setValue(options?.defaultValue);
},
};
}