diff options
Diffstat (limited to 'packages/exchange-backoffice-ui')
31 files changed, 1673 insertions, 362 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, +} |