cases, account details and new-form screen
This commit is contained in:
parent
dad7d48ed2
commit
64e3705669
@ -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"
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
||||
} {
|
||||
|
9
packages/exchange-backoffice-ui/src/assets/logo-2021.svg
Normal file
9
packages/exchange-backoffice-ui/src/assets/logo-2021.svg
Normal 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 |
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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";
|
||||
|
@ -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";
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
96
packages/exchange-backoffice-ui/src/forms/simplest.ts
Normal file
96
packages/exchange-backoffice-ui/src/forms/simplest.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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>>;
|
||||
@ -14,6 +24,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>>
|
||||
@ -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}
|
||||
|
34
packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx
Normal file
34
packages/exchange-backoffice-ui/src/handlers/InputAmount.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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" />
|
||||
|
@ -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,
|
||||
};
|
||||
|
457
packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx
Normal file
457
packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx
Normal 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}`);
|
||||
}
|
||||
}
|
@ -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,11 +25,28 @@ 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,
|
||||
|
282
packages/exchange-backoffice-ui/src/pages/Cases.tsx
Normal file
282
packages/exchange-backoffice-ui/src/pages/Cases.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -1,5 +0,0 @@
|
||||
import { h } from "preact";
|
||||
|
||||
export function Info() {
|
||||
return <div>Show key and wire info</div>;
|
||||
}
|
78
packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx
Normal file
78
packages/exchange-backoffice-ui/src/pages/NewFormEntry.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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]);
|
||||
}, [officer.value, password.value]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!session.value) {
|
||||
session.update(createNewSessionId());
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { value: sessionId } = session;
|
||||
if (!sessionId) {
|
||||
return <div>loading...</div>;
|
||||
}
|
||||
|
||||
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);
|
||||
@ -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">
|
||||
<Form.InputLine
|
||||
label={"Password" as TranslatedString}
|
||||
|
@ -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();
|
||||
}
|
||||
|
81
packages/exchange-backoffice-ui/src/types.ts
Normal file
81
packages/exchange-backoffice-ui/src/types.ts
Normal 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,
|
||||
}
|
@ -24,6 +24,6 @@ function getBrowserLang(): string | undefined {
|
||||
}
|
||||
|
||||
export function useLang(initial?: string): Required<LocalStorageState> {
|
||||
const defaultLang = (getBrowserLang() || initial || "en").substring(0, 2);
|
||||
return useLocalStorage("lang-preference", defaultLang);
|
||||
const defaultValue = (getBrowserLang() || initial || "en").substring(0, 2);
|
||||
return useLocalStorage("lang-preference", { defaultValue: defaultValue });
|
||||
}
|
||||
|
@ -19,6 +19,7 @@
|
||||
* @author Sebastian Javier Marchano (sebasjm)
|
||||
*/
|
||||
|
||||
import { Codec } from "@gnu-taler/taler-util";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import {
|
||||
ObservableMap,
|
||||
@ -27,9 +28,9 @@ import {
|
||||
memoryMap,
|
||||
} from "../utils/observable.js";
|
||||
|
||||
export interface LocalStorageState {
|
||||
value?: string;
|
||||
update: (s: string) => void;
|
||||
export interface LocalStorageState<Type = string> {
|
||||
value?: Type;
|
||||
update: (s: Type) => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
@ -47,33 +48,62 @@ const storage: ObservableMap<string, string> = (function buildStorage() {
|
||||
}
|
||||
})();
|
||||
|
||||
export function useLocalStorage(
|
||||
//with initial value
|
||||
export function useLocalStorage<Type = string>(
|
||||
key: string,
|
||||
initialValue: string,
|
||||
): Required<LocalStorageState>;
|
||||
export function useLocalStorage(key: string): LocalStorageState;
|
||||
export function useLocalStorage(
|
||||
options?: {
|
||||
defaultValue: Type;
|
||||
codec?: Codec<Type>;
|
||||
},
|
||||
): Required<LocalStorageState<Type>>;
|
||||
//without initial value
|
||||
export function useLocalStorage<Type = string>(
|
||||
key: string,
|
||||
initialValue?: string,
|
||||
): LocalStorageState {
|
||||
const [storedValue, setStoredValue] = useState<string | undefined>(
|
||||
(): string | undefined => {
|
||||
return storage.get(key) ?? initialValue;
|
||||
options?: {
|
||||
codec?: Codec<Type>;
|
||||
},
|
||||
): LocalStorageState<Type>;
|
||||
// impl
|
||||
export function useLocalStorage<Type = string>(
|
||||
key: string,
|
||||
options?: {
|
||||
defaultValue?: Type;
|
||||
codec?: Codec<Type>;
|
||||
},
|
||||
): LocalStorageState<Type> {
|
||||
function convert(updated: string | undefined): Type | undefined {
|
||||
if (updated === undefined) return options?.defaultValue; //optional
|
||||
try {
|
||||
return !options?.codec
|
||||
? (updated as Type)
|
||||
: options.codec.decode(JSON.parse(updated));
|
||||
} catch (e) {
|
||||
//decode error
|
||||
return options?.defaultValue;
|
||||
}
|
||||
}
|
||||
const [storedValue, setStoredValue] = useState<Type | undefined>(
|
||||
(): Type | undefined => {
|
||||
const prev = storage.get(key);
|
||||
return convert(prev);
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return storage.onUpdate(key, () => {
|
||||
const newValue = storage.get(key);
|
||||
setStoredValue(newValue ?? initialValue);
|
||||
setStoredValue(convert(newValue));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setValue = (value?: string): void => {
|
||||
const setValue = (value?: Type): void => {
|
||||
if (value === undefined) {
|
||||
storage.delete(key);
|
||||
} else {
|
||||
storage.set(key, value);
|
||||
storage.set(
|
||||
key,
|
||||
options?.codec ? JSON.stringify(value) : (value as string),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -81,7 +111,7 @@ export function useLocalStorage(
|
||||
value: storedValue,
|
||||
update: setValue,
|
||||
reset: () => {
|
||||
setValue(initialValue);
|
||||
setValue(options?.defaultValue);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user