cases, account details and new-form screen

This commit is contained in:
Sebastian 2023-05-25 18:08:20 -03:00
parent dad7d48ed2
commit 64e3705669
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
33 changed files with 1722 additions and 381 deletions

View File

@ -23,39 +23,14 @@ import {
useMemoryStorage, useMemoryStorage,
useNotifications, useNotifications,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import {
/** AbsoluteTime,
* references between forms Codec,
* buildCodecForObject,
* 902.1e codecForAbsoluteTime,
* --> 902.11 (operational legal entity or partnership) codecForString,
* --> 902.12 (a foundation) } from "@gnu-taler/taler-util";
* --> 902.13 (a trust) import logo from "./assets/logo-2021.svg";
* --> 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: "#" },
];
function classNames(...classes: string[]) { function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" "); return classes.filter(Boolean).join(" ");
@ -153,7 +128,7 @@ function LeftMenu() {
)} )}
aria-hidden="true" aria-hidden="true"
/> />
Info Cases
</a> </a>
</li> </li>
<li> <li>
@ -175,7 +150,7 @@ function LeftMenu() {
)} )}
aria-hidden="true" aria-hidden="true"
/> />
Officer Account
</a> </a>
</li> </li>
</ul> </ul>
@ -203,7 +178,7 @@ function LeftMenu() {
</li> </li>
</ul> </ul>
</li> */} </li> */}
<li class="mt-auto"> {/* <li class="mt-auto">
<a <a
href={Pages.settings.url} href={Pages.settings.url}
class={classNames( class={classNames(
@ -224,7 +199,7 @@ function LeftMenu() {
/> />
Settings Settings
</a> </a>
</li> </li> */}
</ul> </ul>
</nav> </nav>
); );
@ -237,26 +212,18 @@ export function Dashboard({
}): VNode { }): VNode {
const [sidebarOpen, setSidebarOpen] = useState(false); 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 ( return (
<Fragment> <Fragment>
<NavigationBar isOpen={sidebarOpen} setOpen={setSidebarOpen}> <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 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"> <div class="flex h-16 shrink-0 items-center">
<img <header class="flex items-center justify-between border-b border-white/5 ">
class="h-8 w-auto" <h1 class="text-base font-semibold leading-7 text-white">
src="https://tailwindui.com/img/logos/mark.svg?color=white" Exchange AML Backoffice
alt="Taler" </h1>
/> </header>
</div> </div>
<LeftMenu /> <LeftMenu />
<div class="text-white text-sm">
<pre ref={logRef}></pre>
</div>
<Footer /> <Footer />
</div> </div>
</NavigationBar> </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 }) { function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
const password = useMemoryStorage("password"); const password = useMemoryStorage("password");
const officer = useLocalStorage("officer"); const officer = useLocalStorage("officer", {
codec: codecForOfficer(),
});
return ( 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"> <div class="relative flex h-16 justify-between">
<div class="relative z-10 flex p-2 lg:hidden">
<button <button
type="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"
/>
{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={() => { onClick={() => {
officer.reset(); onOpenSidebar();
password.reset();
}} }}
class={classNames( 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"
active ? "bg-gray-50" : "", aria-controls="mobile-menu"
"block px-3 py-1 text-sm leading-6 text-gray-900", aria-expanded="false"
)}
> >
Forget account <span class="sr-only">Open menu</span>
</a> <svg
)} class="block h-6 w-6"
</Menu.Item> fill="none"
</Menu.Items> viewBox="0 0 24 24"
</Transition> stroke-width="1.5"
</Menu> stroke="currentColor"
)} aria-hidden="true"
>
<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 }}
/>
</div> </div>
</div> </div>
{/* <div class="relative z-10 flex items-center lg:hidden">dd</div> */}
</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() { function Footer() {
return ( return (
<footer class="absolute bottom-4"> <footer class="absolute bottom-4">
@ -502,7 +539,6 @@ function Notifications() {
{ {
/* <!-- Global notification live region, render this permanently at the end of the document --> */ /* <!-- Global notification live region, render this permanently at the end of the document --> */
} }
console.log("render", ns.length);
return ( return (
<div <div
aria-live="assertive" aria-live="assertive"

View File

@ -1,5 +1,5 @@
import { useTranslationContext } from "@gnu-taler/web-util/browser"; 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 { FlexibleForm } from "./forms/index.js";
import { FormProvider } from "./handlers/FormProvider.js"; import { FormProvider } from "./handlers/FormProvider.js";
import { RenderAllFieldsByUiConfig } from "./handlers/forms.js"; import { RenderAllFieldsByUiConfig } from "./handlers/forms.js";
@ -8,21 +8,25 @@ export function NiceForm<T extends object>({
initial, initial,
onUpdate, onUpdate,
form, form,
onSubmit,
children,
}: { }: {
children?: ComponentChildren;
initial: Partial<T>; initial: Partial<T>;
onSubmit?: (v: T) => void;
form: FlexibleForm<T>; form: FlexibleForm<T>;
onUpdate: (d: Partial<T>) => void; onUpdate?: (d: Partial<T>) => void;
}) { }) {
const { i18n } = useTranslationContext();
return ( return (
<FormProvider <FormProvider
initialValue={initial} initialValue={initial}
onUpdate={onUpdate} onUpdate={onUpdate}
onSubmit={() => {}} onSubmit={onSubmit}
computeFormState={form.behavior} computeFormState={form.behavior}
> >
<div class="space-y-10 divide-y -mt-5 divide-gray-900/10"> <div class="space-y-10 divide-y -mt-5 divide-gray-900/10">
{form.design.map((section, i) => { {form.design.map((section, i) => {
if (!section) return <Fragment />;
return ( return (
<div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-5 md:grid-cols-3"> <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"> <div class="px-4 sm:px-0">
@ -49,6 +53,7 @@ export function NiceForm<T extends object>({
); );
})} })}
</div> </div>
{children}
</FormProvider> </FormProvider>
); );
} }

View File

@ -7,28 +7,33 @@ import { decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
* *
* @returns session id as string * @returns session id as string
*/ */
export function createNewSessionId(): string { export function createSalt(): string {
const salt = crypto.getRandomValues(new Uint8Array(8)); const salt = crypto.getRandomValues(new Uint8Array(8));
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer); return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer);
} }
export interface Account {
accountId: string;
secret: CryptoKey;
}
/** /**
* Restore previous session and unlock account * Restore previous session and unlock account
* *
* @param sessionId string from which crypto params will be derived * @param salt string from which crypto params will be derived
* @param accountId secured private key * @param key secured private key
* @param password password for the private key * @param password password for the private key
* @returns * @returns
*/ */
export async function unlockAccount( export async function unlockAccount(
sessionId: string, salt: string,
accountId: string, key: string,
password: string, password: string,
) { ): Promise<Account> {
const key = str2ab(window.atob(accountId)); 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); const publicKey = await getPublicFromPrivate(privateKey);
@ -36,9 +41,9 @@ export async function unlockAccount(
throw new Error(String(e)); 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 * @param password
* @returns * @returns
*/ */
export async function createNewAccount(sessionId: string, password: string) { export async function createNewAccount(password: string) {
const { privateKey, publicKey } = await createPair(); const { privateKey } = await createPair();
const salt = createSalt();
const protectedPrivKey = await protectWithPassword( const protectedPrivKey = await protectWithPassword(
privateKey, privateKey,
sessionId, salt,
password, password,
); );
@ -64,14 +70,14 @@ export async function createNewAccount(sessionId: string, password: string) {
// throw new Error(String(e)); // throw new Error(String(e));
// }); // });
const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => { // const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
throw new Error(String(e)); // throw new Error(String(e));
}); // });
const pub = btoa(ab2str(pubRaw)); // const pub = btoa(ab2str(pubRaw));
const protectedPriv = btoa(ab2str(protectedPrivKey)); const protectedPriv = btoa(ab2str(protectedPrivKey));
return { accountId: protectedPriv, pub }; return { accountId: protectedPriv, salt };
} }
const rsaAlgorithm: RsaHashedKeyGenParams = { const rsaAlgorithm: RsaHashedKeyGenParams = {
@ -97,7 +103,7 @@ async function protectWithPassword(
sessionId: string, sessionId: string,
password: string, password: string,
): Promise<ArrayBuffer> { ): Promise<ArrayBuffer> {
const { salt, initVector: iv } = getCryptoPArameters(sessionId); const { salt, initVector: iv } = getCryptoParameters(sessionId);
const passwordAsKey = await crypto.subtle const passwordAsKey = await crypto.subtle
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [ .importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
"deriveBits", "deriveBits",
@ -139,7 +145,7 @@ async function recoverWithPassword(
sessionId: string, sessionId: string,
password: string, password: string,
): Promise<CryptoKey> { ): Promise<CryptoKey> {
const { salt, initVector: iv } = getCryptoPArameters(sessionId); const { salt, initVector: iv } = getCryptoParameters(sessionId);
const master = await crypto.subtle const master = await crypto.subtle
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [ .importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
@ -231,7 +237,7 @@ function str2ab(str: string) {
return buf; return buf;
} }
function getCryptoPArameters(sessionId: string): { function getCryptoParameters(sessionId: string): {
salt: Uint8Array; salt: Uint8Array;
initVector: Uint8Array; initVector: Uint8Array;
} { } {

View File

@ -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>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,2 +1,30 @@
declare const __VERSION__: string; declare const __VERSION__: string;
declare const __GIT_HASH__: 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;

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -115,8 +116,8 @@ export const v1: FlexibleForm<Form902_11e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_11e.Form>, v: Partial<Form902_11.Form>,
): FormState<Form902_11e.Form> { ): FormState<Form902_11.Form> {
return { return {
person: { person: {
hidden: hidden:
@ -128,9 +129,9 @@ export const v1: FlexibleForm<Form902_11e.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_11e { namespace Form902_11 {
interface Person { interface Person {
lastName: string; lastName: string;
firstName: string; firstName: string;

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -364,8 +365,8 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_12e.Form>, v: Partial<Form902_12.Form>,
): FormState<Form902_12e.Form> { ): FormState<Form902_12.Form> {
return { return {
founders: { founders: {
elements: (v.founders ?? []).map((f) => { elements: (v.founders ?? []).map((f) => {
@ -390,9 +391,9 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_12e { namespace Form902_12 {
interface Foundation { interface Foundation {
name: string; name: string;
type: "discretionary" | "non-discretionary"; type: "discretionary" | "non-discretionary";

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -441,8 +442,8 @@ export const v1: FlexibleForm<Form902_13e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_13e.Form>, v: Partial<Form902_13.Form>,
): FormState<Form902_13e.Form> { ): FormState<Form902_13.Form> {
return { return {
settlors: { settlors: {
elements: (v.settlors ?? []).map((f) => { elements: (v.settlors ?? []).map((f) => {
@ -476,9 +477,9 @@ export const v1: FlexibleForm<Form902_13e.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_13e { namespace Form902_13 {
interface Foundation { interface Foundation {
name: string; name: string;
type: "discretionary" | "non-discretionary"; type: "discretionary" | "non-discretionary";

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -160,17 +161,17 @@ export const v1: FlexibleForm<Form902_15e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_15e.Form>, v: Partial<Form902_15.Form>,
): FormState<Form902_15e.Form> { ): FormState<Form902_15.Form> {
return { return {
when: { when: {
disabled: true, disabled: true,
}, },
}; };
}, },
}; });
namespace Form902_15e { namespace Form902_15 {
interface Person { interface Person {
fullName: string; fullName: string;
address: string; address: string;

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FlexibleForm, languageList } from "./index.js"; import { FlexibleForm, languageList } from "./index.js";
import { FormState } from "../handlers/FormProvider.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -512,8 +513,8 @@ export const v1: FlexibleForm<Form902_1e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_1e.Form>, v: Partial<Form902_1.Form>,
): FormState<Form902_1e.Form> { ): FormState<Form902_1.Form> {
return { return {
fullName: { fullName: {
disabled: true, disabled: true,
@ -606,9 +607,9 @@ export const v1: FlexibleForm<Form902_1e.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_1e { namespace Form902_1 {
interface LegalEntityCustomer { interface LegalEntityCustomer {
companyName: string; companyName: string;
domicile: string; domicile: string;

View File

@ -4,8 +4,9 @@ import { FlexibleForm } from "./index.js";
import { ArrowRightIcon } from "@heroicons/react/24/outline"; import { ArrowRightIcon } from "@heroicons/react/24/outline";
import { h as create } from "preact"; import { h as create } from "preact";
import { ChevronRightIcon } from "@heroicons/react/24/solid"; 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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -745,7 +746,7 @@ export const v1: FlexibleForm<Form902_4.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_4 { namespace Form902_4 {
export interface Form { export interface Form {

View File

@ -5,8 +5,9 @@ import {
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm, currencyList } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -230,8 +231,8 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_12e.Form>, v: Partial<Form902_5.Form>,
): FormState<Form902_12e.Form> { ): FormState<Form902_5.Form> {
return { return {
when: { when: {
disabled: true, disabled: true,
@ -243,9 +244,9 @@ export const v1: FlexibleForm<Form902_12e.Form> = {
}, },
}; };
}, },
}; });
namespace Form902_12e { namespace Form902_5 {
export interface Form { export interface Form {
customer: string; customer: string;
fullName: string; fullName: string;

View File

@ -1,8 +1,9 @@
import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util"; import { AbsoluteTime, TranslatedString } from "@gnu-taler/taler-util";
import { FormState } from "../handlers/FormProvider.js"; import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.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", versionId: "2023-05-15",
design: [ design: [
{ {
@ -104,17 +105,17 @@ export const v1: FlexibleForm<Form902_9e.Form> = {
}, },
], ],
behavior: function formBehavior( behavior: function formBehavior(
v: Partial<Form902_9e.Form>, v: Partial<Form902_9.Form>,
): FormState<Form902_9e.Form> { ): FormState<Form902_9.Form> {
return { return {
when: { when: {
disabled: true, disabled: true,
}, },
}; };
}, },
}; });
namespace Form902_9e { namespace Form902_9 {
interface Person { interface Person {
surname: string; surname: string;
firstName: string; firstName: string;

View File

@ -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;
}
}

View File

@ -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 { 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> { export interface FormType<T> {
value: MutableRef<Partial<T>>; value: MutableRef<Partial<T>>;
@ -14,6 +24,8 @@ export const FormContext = createContext<FormType<any>>({});
export type FormState<T> = { export type FormState<T> = {
[field in keyof T]?: T[field] extends AbsoluteTime [field in keyof T]?: T[field] extends AbsoluteTime
? Partial<InputFieldState>
: T[field] extends AmountJson
? Partial<InputFieldState> ? Partial<InputFieldState>
: T[field] extends Array<infer P> : T[field] extends Array<infer P>
? Partial<InputArrayFieldState<P>> ? Partial<InputArrayFieldState<P>>
@ -40,22 +52,31 @@ export interface InputArrayFieldState<T> extends InputFieldState {
export function FormProvider<T>({ export function FormProvider<T>({
children, children,
initialValue, initialValue,
onUpdate, onUpdate: notify,
onSubmit, onSubmit,
computeFormState, computeFormState,
}: { }: {
initialValue?: Partial<T>; initialValue?: Partial<T>;
onUpdate?: (v: Partial<T>) => void; onUpdate?: (v: Partial<T>) => void;
onSubmit: (v: T) => void; onSubmit?: (v: T) => void;
computeFormState?: (v: T) => FormState<T>; computeFormState?: (v: T) => FormState<T>;
children: ComponentChildren; children: ComponentChildren;
}): VNode { }): VNode {
const value = useRef(initialValue ?? {}); // const value = useRef(initialValue ?? {});
useEffect(() => { // useEffect(() => {
return function onUnload() { // return function onUnload() {
value.current = initialValue ?? {}; // 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 ( return (
<FormContext.Provider <FormContext.Provider
value={{ initialValue, value, onUpdate, computeFormState }} value={{ initialValue, value, onUpdate, computeFormState }}
@ -64,7 +85,7 @@ export function FormProvider<T>({
onSubmit={(e) => { onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
//@ts-ignore //@ts-ignore
onSubmit(value.current); if (onSubmit) onSubmit(value.current);
}} }}
> >
{children} {children}

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View File

@ -3,15 +3,15 @@ import { Fragment, VNode, h } from "preact";
import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js"; import { LabelWithTooltipMaybeRequired, UIFormProps } from "./InputLine.js";
import { useField } from "./useField.js"; import { useField } from "./useField.js";
export interface Choice { export interface Choice<V> {
label: TranslatedString; label: TranslatedString;
description?: TranslatedString; description?: TranslatedString;
value: string; value: V;
} }
export function InputChoiceStacked<T extends object, K extends keyof T>( export function InputChoiceStacked<T extends object, K extends keyof T>(
props: { props: {
choices: Choice[]; choices: Choice<T[K]>[];
} & UIFormProps<T, K>, } & UIFormProps<T, K>,
): VNode { ): VNode {
const { const {
@ -41,6 +41,10 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
<fieldset class="mt-2"> <fieldset class="mt-2">
<div class="space-y-4"> <div class="space-y-4">
{choices.map((choice) => { {choices.map((choice) => {
// const currentValue = !converter
// ? choice.value
// : converter.fromStringUI(choice.value) ?? "";
let clazz = 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"; "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) { if (choice.value === value) {
@ -49,12 +53,18 @@ export function InputChoiceStacked<T extends object, K extends keyof T>(
} else { } else {
clazz += " border-gray-300"; clazz += " border-gray-300";
} }
return ( return (
<label class={clazz}> <label class={clazz}>
<input <input
type="radio" type="radio"
name="server-size" name="server-size"
defaultValue={choice.value} // defaultValue={choice.value}
value={
(!converter
? (choice.value as string)
: converter?.toStringUI(choice.value)) ?? ""
}
onClick={(e) => { onClick={(e) => {
onChange( onChange(
(value === choice.value (value === choice.value

View File

@ -250,7 +250,8 @@ export function InputLine<T extends object, K extends keyof T>(
onChange(fromString(e.currentTarget.value)); onChange(fromString(e.currentTarget.value));
}} }}
placeholder={placeholder ? placeholder : undefined} placeholder={placeholder ? placeholder : undefined}
defaultValue={toString(value)} value={toString(value) ?? ""}
// defaultValue={toString(value)}
disabled={state.disabled} disabled={state.disabled}
aria-invalid={showError} aria-invalid={showError}
// aria-describedby="email-error" // aria-describedby="email-error"
@ -269,7 +270,8 @@ export function InputLine<T extends object, K extends keyof T>(
onChange(fromString(e.currentTarget.value)); onChange(fromString(e.currentTarget.value));
}} }}
placeholder={placeholder ? placeholder : undefined} placeholder={placeholder ? placeholder : undefined}
defaultValue={toString(value)} value={toString(value) ?? ""}
// defaultValue={toString(value)}
disabled={state.disabled} disabled={state.disabled}
aria-invalid={showError} aria-invalid={showError}
// aria-describedby="email-error" // aria-describedby="email-error"

View File

@ -13,8 +13,10 @@ import { Group } from "./Group.js";
import { InputSelectOne } from "./InputSelectOne.js"; import { InputSelectOne } from "./InputSelectOne.js";
import { FormProvider } from "./FormProvider.js"; import { FormProvider } from "./FormProvider.js";
import { InputLine } from "./InputLine.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 = { type DoubleColumnFormSection = {
title: TranslatedString; title: TranslatedString;
@ -35,8 +37,10 @@ type FieldType<T extends object = any, K extends keyof T = any> = {
text: Parameters<typeof InputText<T, K>>[0]; text: Parameters<typeof InputText<T, K>>[0];
textArea: Parameters<typeof InputTextArea<T, K>>[0]; textArea: Parameters<typeof InputTextArea<T, K>>[0];
choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0]; choiceStacked: Parameters<typeof InputChoiceStacked<T, K>>[0];
choiceHorizontal: Parameters<typeof InputChoiceHorizontal<T, K>>[0];
date: Parameters<typeof InputDate<T, K>>[0]; date: Parameters<typeof InputDate<T, K>>[0];
integer: Parameters<typeof InputInteger<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: "caption"; props: FieldType["caption"] }
| { type: "array"; props: FieldType["array"] } | { type: "array"; props: FieldType["array"] }
| { type: "file"; props: FieldType["file"] } | { type: "file"; props: FieldType["file"] }
| { type: "amount"; props: FieldType["amount"] }
| { type: "selectOne"; props: FieldType["selectOne"] } | { type: "selectOne"; props: FieldType["selectOne"] }
| { type: "selectMultiple"; props: FieldType["selectMultiple"] } | { type: "selectMultiple"; props: FieldType["selectMultiple"] }
| { type: "text"; props: FieldType["text"] } | { type: "text"; props: FieldType["text"] }
| { type: "textArea"; props: FieldType["textArea"] } | { type: "textArea"; props: FieldType["textArea"] }
| { type: "choiceStacked"; props: FieldType["choiceStacked"] } | { type: "choiceStacked"; props: FieldType["choiceStacked"] }
| { type: "choiceHorizontal"; props: FieldType["choiceHorizontal"] }
| { type: "integer"; props: FieldType["integer"] } | { type: "integer"; props: FieldType["integer"] }
| { type: "date"; props: FieldType["date"] }; | { type: "date"; props: FieldType["date"] };
@ -79,11 +85,15 @@ const UIFormConfiguration: UIFormFieldMap = {
date: InputDate, date: InputDate,
//@ts-ignore //@ts-ignore
choiceStacked: InputChoiceStacked, choiceStacked: InputChoiceStacked,
//@ts-ignore
choiceHorizontal: InputChoiceHorizontal,
integer: InputInteger, integer: InputInteger,
//@ts-ignore //@ts-ignore
selectOne: InputSelectOne, selectOne: InputSelectOne,
//@ts-ignore //@ts-ignore
selectMultiple: InputSelectMultiple, selectMultiple: InputSelectMultiple,
//@ts-ignore
amount: InputAmount,
}; };
export function RenderAllFieldsByUiConfig({ 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>; 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> { export function createNewForm<T extends object>() {
return { const res: FormSet<T> = {
Provider: FormProvider, Provider: FormProvider,
InputLine: InputLine, InputLine: () => InputLine,
InputChoiceHorizontal: () => InputChoiceHorizontal,
};
return {
Provider: res.Provider,
InputLine: res.InputLine(),
InputChoiceHorizontal: res.InputChoiceHorizontal(),
}; };
} }

View File

@ -1,9 +1,5 @@
import { TargetedEvent, useContext, useState } from "preact/compat"; import { useContext, useState } from "preact/compat";
import { import { FormContext, InputFieldState } from "./FormProvider.js";
FormContext,
InputArrayFieldState,
InputFieldState,
} from "./FormProvider.js";
export interface InputFieldHandler<Type> { export interface InputFieldHandler<Type> {
value: Type; value: Type;
@ -21,11 +17,13 @@ export function useField<T extends object, K extends keyof T>(
computeFormState, computeFormState,
onUpdate: notifyUpdate, onUpdate: notifyUpdate,
} = useContext(FormContext); } = useContext(FormContext);
type P = typeof name; type P = typeof name;
type V = T[P]; type V = T[P];
const formState = computeFormState ? computeFormState(formValue.current) : {}; const formState = computeFormState ? computeFormState(formValue.current) : {};
const fieldValue = readField(formValue.current, String(name)) as V; 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 [currentValue, setCurrentValue] = useState<any | undefined>(fieldValue);
const fieldState = const fieldState =
readField<Partial<InputFieldState>>(formState, String(name)) ?? {}; readField<Partial<InputFieldState>>(formState, String(name)) ?? {};
@ -66,10 +64,23 @@ export function useField<T extends object, K extends keyof T>(
* @param name * @param name
* @returns * @returns
*/ */
function readField<T>(object: any, name: string): T | undefined { function readField<T>(
return name object: any,
.split(".") name: string,
.reduce((prev, current) => prev && prev[current], object); 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 { function setValueDeeper(object: any, names: string[], value: any): any {

View File

@ -30,6 +30,8 @@
/> />
<link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" />
<title>Exchange Backoffice</title> <title>Exchange Backoffice</title>
<!-- Optional customization script. -->
<script src="exchange-backofice-ui-settings.js"></script>
<!-- Entry point for the SPA. --> <!-- Entry point for the SPA. -->
<script type="module" src="index.js"></script> <script type="module" src="index.js"></script>
<link rel="stylesheet" href="index.css" /> <link rel="stylesheet" href="index.css" />

View File

@ -4,15 +4,26 @@ import { AntiMoneyLaunderingForm } from "./pages/AntiMoneyLaunderingForm.js";
import { Welcome } from "./pages/Welcome.js"; import { Welcome } from "./pages/Welcome.js";
import { PageEntry, pageDefinition } from "./route.js"; import { PageEntry, pageDefinition } from "./route.js";
import { Officer } from "./pages/Officer.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 = { const home: PageEntry = {
url: "#/", url: "#/",
view: Home, view: Home,
}; };
const info: PageEntry = { const cases: PageEntry = {
url: "#/info", url: "#/cases",
view: Info, 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 = { const settings: PageEntry = {
@ -32,4 +43,13 @@ const form: PageEntry<{ number?: string }> = {
view: AntiMoneyLaunderingForm, view: AntiMoneyLaunderingForm,
}; };
export const Pages = { home, info, officer, settings, welcome, form }; export const Pages = {
home,
info: cases,
officer,
details: account,
settings,
welcome,
form,
newFormEntry,
};

View File

@ -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}`);
}
}

View File

@ -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_4e_v1 } from "../forms/902_4e.js";
import { v1 as form_902_5e_v1 } from "../forms/902_5e.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 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 { DocumentDuplicateIcon } from "@heroicons/react/24/solid";
import { AbsoluteTime } from "@gnu-taler/taler-util"; 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 }) { export function AntiMoneyLaunderingForm({ number }: { number?: string }) {
const selectedForm = Number.parseInt(number ?? "0", 10); const selectedForm = Number.parseInt(number ?? "0", 10);
@ -22,11 +25,28 @@ export function AntiMoneyLaunderingForm({ number }: { number?: string }) {
when: AbsoluteTime.now(), when: AbsoluteTime.now(),
}; };
return ( 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 = [ export const allForms = [
{
name: "Simple comment",
icon: DocumentDuplicateIcon,
impl: simplest,
},
{ {
name: "Identification form (902.1e)", name: "Identification form (902.1e)",
icon: DocumentDuplicateIcon, icon: DocumentDuplicateIcon,

View File

@ -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>
);
}

View File

@ -1,5 +0,0 @@
import { h } from "preact";
export function Info() {
return <div>Show key and wire info</div>;
}

View File

@ -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>
);
}

View File

@ -4,69 +4,60 @@ import {
notifyInfo, notifyInfo,
useLocalStorage, useLocalStorage,
useMemoryStorage, useMemoryStorage,
useTranslationContext,
} from "@gnu-taler/web-util/browser"; } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact"; import { VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import {
Account,
UnwrapKeyError, UnwrapKeyError,
createNewAccount, createNewAccount,
createNewSessionId,
unlockAccount, unlockAccount,
} from "../account.js"; } from "../account.js";
import { createNewForm } from "../handlers/forms.js"; import { createNewForm } from "../handlers/forms.js";
import { Officer, codecForOfficer } from "../Dashboard.js";
export function Officer() { export function Officer() {
const password = useMemoryStorage("password"); const password = useMemoryStorage("password");
const session = useLocalStorage("session"); const officer = useLocalStorage("officer", {
const officer = useLocalStorage("officer"); codec: codecForOfficer(),
const [keys, setKeys] = useState({ accountId: "", pub: "" }); });
const [keys, setKeys] = useState<Account>();
useEffect(() => { useEffect(() => {
if ( if (officer.value === undefined || password.value === undefined) {
officer.value === undefined ||
session.value === undefined ||
password.value === undefined
) {
return; return;
} }
unlockAccount(session.value, officer.value, password.value)
unlockAccount(officer.value.salt, officer.value.key, password.value)
.then((keys) => setKeys(keys ?? { accountId: "", pub: "" })) .then((keys) => setKeys(keys ?? { accountId: "", pub: "" }))
.catch((e) => { .catch((e) => {
if (e instanceof UnwrapKeyError) { if (e instanceof UnwrapKeyError) {
console.log(e); console.log(e);
} }
}); });
}, [officer.value, session.value, password.value]); }, [officer.value, password.value]);
useEffect(() => { if (
if (!session.value) { officer.value === undefined ||
session.update(createNewSessionId()); !officer.value.key ||
} !officer.value.salt
}, []); ) {
const { value: sessionId } = session;
if (!sessionId) {
return <div>loading...</div>;
}
if (officer.value === undefined) {
return ( return (
<CreateAccount <CreateAccount
sessionId={sessionId} onNewAccount={(salt, key, pwd) => {
onNewAccount={(id) => { password.update(pwd);
password.reset(); officer.update({ salt, when: { t_ms: Date.now() }, key });
officer.update(id);
}} }}
/> />
); );
} }
console.log("pwd", password.value);
if (password.value === undefined) { if (password.value === undefined) {
return ( return (
<UnlockAccount <UnlockAccount
sessionId={sessionId} salt={officer.value.salt}
accountId={officer.value} sealedKey={officer.value.key}
onAccountUnlocked={(pwd) => { onAccountUnlocked={(pwd) => {
password.update(pwd); password.update(pwd);
}} }}
@ -76,42 +67,59 @@ export function Officer() {
return ( return (
<div> <div>
<div>Officer</div>
<h1>{sessionId}</h1>
<h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> <h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 ">
Public key Public key
</h1> </h1>
<div> <div class="max-w-xl text-base leading-7 text-gray-700 lg:max-w-lg">
<p class="mt-6 leading-8 text-gray-700 break-all"> <p class="mt-6 font-mono break-all">{keys?.accountId}</p>
-----BEGIN PUBLIC KEY-----
<div>{keys.pub}</div>
-----END PUBLIC KEY-----
</p>
</div> </div>
<h1 class="my-2 text-3xl font-bold tracking-tight text-gray-900 "> <p>
Private key <a
</h1> href={`mailto:aml@exchange.taler.net?body=${encodeURIComponent(
<div> `I want my AML account\n\n\nPubKey: ${keys?.accountId}`,
<p class="mt-6 leading-8 text-gray-700 break-all"> )}`}
-----BEGIN PRIVATE KEY----- target="_blank"
<div>{keys.accountId}</div> rel="noreferrer"
-----END PRIVATE KEY----- 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> </p>
</div>
</div> </div>
); );
} }
function CreateAccount({ function CreateAccount({
sessionId,
onNewAccount, onNewAccount,
}: { }: {
sessionId: string; onNewAccount: (salt: string, accountId: string, password: string) => void;
onNewAccount: (accountId: string) => void;
}): VNode { }): VNode {
const { i18n } = useTranslationContext();
const Form = createNewForm<{ const Form = createNewForm<{
email: string;
password: string; password: string;
repeat: string;
}>(); }>();
return ( return (
@ -125,24 +133,50 @@ function CreateAccount({
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <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"> <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
<Form.Provider <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) => { onSubmit={async (v) => {
const keys = await createNewAccount(sessionId, v.password); const keys = await createNewAccount(v.password);
onNewAccount(keys.accountId); onNewAccount(keys.salt, keys.accountId, v.password);
}} }}
> >
<div class="mb-4"> <div class="mb-4">
<Form.InputLine <Form.InputLine
label={"Email" as TranslatedString} label={"Password" as TranslatedString}
name="email" name="password"
type="email" type="password"
help={
"lower and upper case letters, number and special character" as TranslatedString
}
required required
/> />
</div> </div>
<div class="mb-4"> <div class="mb-4">
<Form.InputLine <Form.InputLine
label={"Password" as TranslatedString} label={"Repeat password" as TranslatedString}
name="password" name="repeat"
type="password" type="password"
required required
/> />
@ -164,17 +198,15 @@ function CreateAccount({
} }
function UnlockAccount({ function UnlockAccount({
sessionId, salt,
accountId, sealedKey,
onAccountUnlocked, onAccountUnlocked,
}: { }: {
sessionId: string; salt: string;
accountId: string; sealedKey: string;
onAccountUnlocked: (password: string) => void; onAccountUnlocked: (password: string) => void;
}): VNode { }): VNode {
const Form = createNewForm<{ const Form = createNewForm<{
sessionId: string;
accountId: string;
password: string; password: string;
}>(); }>();
@ -182,34 +214,21 @@ function UnlockAccount({
<div class="flex min-h-full flex-col "> <div class="flex min-h-full flex-col ">
<div class="sm:mx-auto sm:w-full sm:max-w-md"> <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"> <h2 class="mt-6 text-center text-2xl font-bold leading-9 tracking-tight text-gray-900">
Unlock account Account locked
</h2> </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>
<div class="mt-10 sm:mx-auto sm:w-full sm:max-w-[480px] "> <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"> <div class="bg-gray-100 px-6 py-6 shadow sm:rounded-lg sm:px-12">
<Form.Provider <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) => { onSubmit={async (v) => {
try { try {
// test login // test login
await unlockAccount(sessionId, accountId, v.password); await unlockAccount(salt, sealedKey, v.password);
onAccountUnlocked(v.password ?? ""); onAccountUnlocked(v.password ?? "");
notifyInfo("Account unlocked" as TranslatedString); notifyInfo("Account unlocked" as TranslatedString);
@ -225,21 +244,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"> <div class="mb-4">
<Form.InputLine <Form.InputLine
label={"Password" as TranslatedString} label={"Password" as TranslatedString}

View File

@ -1,5 +1,5 @@
import { createHashHistory } from "history"; import { createHashHistory } from "history";
import { VNode } from "preact"; import { h as create, VNode } from "preact";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
const history = createHashHistory(); const history = createHashHistory();
@ -64,7 +64,7 @@ export function Router({
}): VNode { }): VNode {
const current = useCurrentLocation(pageList); const current = useCurrentLocation(pageList);
if (current !== undefined) { if (current !== undefined) {
return current.page.view(current.values ?? {}); return create(current.page.view, current.values);
} }
return onNotFound(); return onNotFound();
} }

View File

@ -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,
}

View File

@ -24,6 +24,6 @@ function getBrowserLang(): string | undefined {
} }
export function useLang(initial?: string): Required<LocalStorageState> { export function useLang(initial?: string): Required<LocalStorageState> {
const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2); const defaultValue = (getBrowserLang() || initial || "en").substring(0, 2);
return useLocalStorage("lang-preference", defaultLang); return useLocalStorage("lang-preference", { defaultValue: defaultValue });
} }

View File

@ -19,6 +19,7 @@
* @author Sebastian Javier Marchano (sebasjm) * @author Sebastian Javier Marchano (sebasjm)
*/ */
import { Codec } from "@gnu-taler/taler-util";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import {
ObservableMap, ObservableMap,
@ -27,9 +28,9 @@ import {
memoryMap, memoryMap,
} from "../utils/observable.js"; } from "../utils/observable.js";
export interface LocalStorageState { export interface LocalStorageState<Type = string> {
value?: string; value?: Type;
update: (s: string) => void; update: (s: Type) => void;
reset: () => 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, key: string,
initialValue: string, options?: {
): Required<LocalStorageState>; defaultValue: Type;
export function useLocalStorage(key: string): LocalStorageState; codec?: Codec<Type>;
export function useLocalStorage( },
): Required<LocalStorageState<Type>>;
//without initial value
export function useLocalStorage<Type = string>(
key: string, key: string,
initialValue?: string, options?: {
): LocalStorageState { codec?: Codec<Type>;
const [storedValue, setStoredValue] = useState<string | undefined>( },
(): string | undefined => { ): LocalStorageState<Type>;
return storage.get(key) ?? initialValue; // 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(() => { useEffect(() => {
return storage.onUpdate(key, () => { return storage.onUpdate(key, () => {
const newValue = storage.get(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) { if (value === undefined) {
storage.delete(key); storage.delete(key);
} else { } 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, value: storedValue,
update: setValue, update: setValue,
reset: () => { reset: () => {
setValue(initialValue); setValue(options?.defaultValue);
}, },
}; };
} }