accounts and notifications
This commit is contained in:
parent
35cc13e229
commit
0544b8358a
@ -3,19 +3,26 @@ import {
|
|||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
UserIcon,
|
UserIcon,
|
||||||
|
XCircleIcon,
|
||||||
} from "@heroicons/react/20/solid";
|
} from "@heroicons/react/20/solid";
|
||||||
import {
|
import {
|
||||||
Bars3Icon,
|
Bars3Icon,
|
||||||
BellIcon,
|
BellIcon,
|
||||||
|
CheckCircleIcon,
|
||||||
Cog6ToothIcon,
|
Cog6ToothIcon,
|
||||||
XMarkIcon,
|
XMarkIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import { ComponentChildren, Fragment, VNode, h } from "preact";
|
import { ComponentChildren, Fragment, VNode, h } from "preact";
|
||||||
import { ForwardedRef, forwardRef } from "preact/compat";
|
import { ForwardedRef, forwardRef } from "preact/compat";
|
||||||
import { useRef, useState } from "preact/hooks";
|
import { useEffect, useRef, useState } from "preact/hooks";
|
||||||
import { Pages } from "./pages.js";
|
import { Pages } from "./pages.js";
|
||||||
import { Router, useCurrentLocation } from "./route.js";
|
import { Router, useCurrentLocation } from "./route.js";
|
||||||
import { InformationCircleIcon } from "@heroicons/react/24/solid";
|
import { InformationCircleIcon } from "@heroicons/react/24/solid";
|
||||||
|
import {
|
||||||
|
useLocalStorage,
|
||||||
|
useMemoryStorage,
|
||||||
|
useNotifications,
|
||||||
|
} from "@gnu-taler/web-util/browser";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* references between forms
|
* references between forms
|
||||||
@ -259,6 +266,7 @@ export function Dashboard({
|
|||||||
setSidebarOpen(true);
|
setSidebarOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Notifications />
|
||||||
<main class="py-10 px-4 sm:px-6 lg:px-8">
|
<main class="py-10 px-4 sm:px-6 lg:px-8">
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<Router
|
<Router
|
||||||
@ -355,6 +363,9 @@ function NavigationBar({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
|
function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
|
||||||
|
const password = useMemoryStorage("password");
|
||||||
|
const officer = useLocalStorage("officer");
|
||||||
|
|
||||||
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="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
|
<button
|
||||||
@ -402,7 +413,9 @@ function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Profile dropdown */}
|
{officer.value === undefined ? (
|
||||||
|
<div />
|
||||||
|
) : (
|
||||||
<Menu
|
<Menu
|
||||||
as="div"
|
as="div"
|
||||||
/* @ts-ignore */
|
/* @ts-ignore */
|
||||||
@ -420,7 +433,8 @@ function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
|
|||||||
class="ml-4 text-sm font-semibold leading-6 text-gray-900"
|
class="ml-4 text-sm font-semibold leading-6 text-gray-900"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
Tom Cook
|
{/* Tom Cook */}
|
||||||
|
{officer.value?.substring(0, 6)}
|
||||||
</span>
|
</span>
|
||||||
<ChevronDownIcon
|
<ChevronDownIcon
|
||||||
class="ml-2 h-5 w-5 text-gray-400"
|
class="ml-2 h-5 w-5 text-gray-400"
|
||||||
@ -437,25 +451,28 @@ function TopBar({ onOpenSidebar }: { onOpenSidebar: () => void }) {
|
|||||||
leaveFrom="transform opacity-100 scale-100"
|
leaveFrom="transform opacity-100 scale-100"
|
||||||
leaveTo="transform opacity-0 scale-95"
|
leaveTo="transform opacity-0 scale-95"
|
||||||
>
|
>
|
||||||
<Menu.Items class="absolute right-0 z-10 mt-2.5 w-32 origin-top-right rounded-md bg-white py-2 shadow-lg ring-1 ring-gray-900/5 focus:outline-none">
|
<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">
|
||||||
{userNavigation.map((item) => (
|
<Menu.Item>
|
||||||
<Menu.Item key={item.name}>
|
|
||||||
{({ active }: { active: boolean }) => (
|
{({ active }: { active: boolean }) => (
|
||||||
<a
|
<a
|
||||||
href={item.href}
|
// href={item.href}
|
||||||
|
onClick={() => {
|
||||||
|
officer.reset();
|
||||||
|
password.reset();
|
||||||
|
}}
|
||||||
class={classNames(
|
class={classNames(
|
||||||
active ? "bg-gray-50" : "",
|
active ? "bg-gray-50" : "",
|
||||||
"block px-3 py-1 text-sm leading-6 text-gray-900",
|
"block px-3 py-1 text-sm leading-6 text-gray-900",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.name}
|
Forget account
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -473,3 +490,115 @@ function Footer() {
|
|||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Notifications() {
|
||||||
|
const ns = useNotifications();
|
||||||
|
|
||||||
|
// useEffect(() => {
|
||||||
|
// if (ns.length) {
|
||||||
|
// // remove notifications after some timeout
|
||||||
|
// }
|
||||||
|
// }, []);
|
||||||
|
{
|
||||||
|
/* <!-- Global notification live region, render this permanently at the end of the document --> */
|
||||||
|
}
|
||||||
|
console.log("render", ns.length);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
aria-live="assertive"
|
||||||
|
class="pointer-events-none fixed inset-0 flex items-end px-4 py-6 sm:items-start sm:p-6 z-50"
|
||||||
|
>
|
||||||
|
<div class="flex w-full flex-col items-center space-y-4 sm:items-end ">
|
||||||
|
{/* <!--
|
||||||
|
Notification panel, dynamically insert this into the live region when it needs to be displayed
|
||||||
|
|
||||||
|
Entering: "transform ease-out duration-300 transition"
|
||||||
|
From: "translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
|
||||||
|
To: "translate-y-0 opacity-100 sm:translate-x-0"
|
||||||
|
Leaving: "transition ease-in duration-100"
|
||||||
|
From: "opacity-100"
|
||||||
|
To: "opacity-0"
|
||||||
|
--> */}
|
||||||
|
{ns.map(({ message, remove }) => {
|
||||||
|
switch (message.type) {
|
||||||
|
case "error": {
|
||||||
|
return (
|
||||||
|
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 ">
|
||||||
|
<div class="p-4 ">
|
||||||
|
<div class="flex items-start ">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<XCircleIcon class="h-6 w-6 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{message.title}
|
||||||
|
</p>
|
||||||
|
{message.description && (
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
{message.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "info": {
|
||||||
|
return (
|
||||||
|
<div class="pointer-events-auto w-full max-w-sm overflow-hidden rounded-lg bg-white shadow-lg ring-1 ring-black ring-opacity-5 ">
|
||||||
|
<div class="p-4 ">
|
||||||
|
<div class="flex items-start ">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<CheckCircleIcon class="h-6 w-6 text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 w-0 flex-1 pt-0.5">
|
||||||
|
<p class="text-sm font-medium text-gray-900">
|
||||||
|
{message.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex flex-shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={remove}
|
||||||
|
class="inline-flex rounded-md bg-white text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<svg
|
||||||
|
class="h-5 w-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M6.28 5.22a.75.75 0 00-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 101.06 1.06L10 11.06l3.72 3.72a.75.75 0 101.06-1.06L11.06 10l3.72-3.72a.75.75 0 00-1.06-1.06L10 8.94 6.28 5.22z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
@ -18,6 +18,7 @@ export function NiceForm<T extends object>({
|
|||||||
<FormProvider
|
<FormProvider
|
||||||
initialValue={initial}
|
initialValue={initial}
|
||||||
onUpdate={onUpdate}
|
onUpdate={onUpdate}
|
||||||
|
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">
|
||||||
|
243
packages/exchange-backoffice-ui/src/account.ts
Normal file
243
packages/exchange-backoffice-ui/src/account.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
import { decodeCrock, encodeCrock } from "@gnu-taler/taler-util";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new session id from which it will
|
||||||
|
* be derive the crypto parameters from
|
||||||
|
* securing the private key
|
||||||
|
*
|
||||||
|
* @returns session id as string
|
||||||
|
*/
|
||||||
|
export function createNewSessionId(): string {
|
||||||
|
const salt = crypto.getRandomValues(new Uint8Array(8));
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||||
|
return encodeCrock(salt.buffer) + "-" + encodeCrock(iv.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore previous session and unlock account
|
||||||
|
*
|
||||||
|
* @param sessionId string from which crypto params will be derived
|
||||||
|
* @param accountId secured private key
|
||||||
|
* @param password password for the private key
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function unlockAccount(
|
||||||
|
sessionId: string,
|
||||||
|
accountId: string,
|
||||||
|
password: string,
|
||||||
|
) {
|
||||||
|
const key = str2ab(window.atob(accountId));
|
||||||
|
|
||||||
|
const privateKey = await recoverWithPassword(key, sessionId, password);
|
||||||
|
|
||||||
|
const publicKey = await getPublicFromPrivate(privateKey);
|
||||||
|
|
||||||
|
const pubRaw = await crypto.subtle.exportKey("spki", publicKey).catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
const pub = btoa(ab2str(pubRaw));
|
||||||
|
|
||||||
|
return { accountId, pub };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create new account (secured private key) under session
|
||||||
|
* secured with the given password
|
||||||
|
*
|
||||||
|
* @param sessionId
|
||||||
|
* @param password
|
||||||
|
* @returns
|
||||||
|
*/
|
||||||
|
export async function createNewAccount(sessionId: string, password: string) {
|
||||||
|
const { privateKey, publicKey } = await createPair();
|
||||||
|
|
||||||
|
const protectedPrivKey = await protectWithPassword(
|
||||||
|
privateKey,
|
||||||
|
sessionId,
|
||||||
|
password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// const privRaw = await crypto.subtle
|
||||||
|
// .exportKey("pkcs8", privateKey)
|
||||||
|
// .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 protectedPriv = btoa(ab2str(protectedPrivKey));
|
||||||
|
|
||||||
|
return { accountId: protectedPriv, pub };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rsaAlgorithm: RsaHashedKeyGenParams = {
|
||||||
|
name: "RSA-OAEP",
|
||||||
|
modulusLength: 2048,
|
||||||
|
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||||
|
hash: "SHA-256",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function createPair(): Promise<CryptoKeyPair> {
|
||||||
|
const key = await crypto.subtle
|
||||||
|
.generateKey(rsaAlgorithm, true, ["encrypt", "decrypt"])
|
||||||
|
.catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
|
||||||
|
async function protectWithPassword(
|
||||||
|
privateKey: CryptoKey,
|
||||||
|
sessionId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<ArrayBuffer> {
|
||||||
|
const { salt, initVector: iv } = getCryptoPArameters(sessionId);
|
||||||
|
const passwordAsKey = await crypto.subtle
|
||||||
|
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
|
||||||
|
"deriveBits",
|
||||||
|
"deriveKey",
|
||||||
|
])
|
||||||
|
.catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
const wrappingKey = await crypto.subtle
|
||||||
|
.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
passwordAsKey,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
true,
|
||||||
|
["wrapKey", "unwrapKey"],
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
const protectedPrivKey = await crypto.subtle
|
||||||
|
.wrapKey("pkcs8", privateKey, wrappingKey, {
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
return protectedPrivKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverWithPassword(
|
||||||
|
value: ArrayBuffer,
|
||||||
|
sessionId: string,
|
||||||
|
password: string,
|
||||||
|
): Promise<CryptoKey> {
|
||||||
|
const { salt, initVector: iv } = getCryptoPArameters(sessionId);
|
||||||
|
|
||||||
|
const master = await crypto.subtle
|
||||||
|
.importKey("raw", textEncoder.encode(password), { name: "PBKDF2" }, false, [
|
||||||
|
"deriveBits",
|
||||||
|
"deriveKey",
|
||||||
|
])
|
||||||
|
.catch((e) => {
|
||||||
|
throw new UnwrapKeyError("starting", String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
const unwrappingKey = await crypto.subtle
|
||||||
|
.deriveKey(
|
||||||
|
{
|
||||||
|
name: "PBKDF2",
|
||||||
|
salt,
|
||||||
|
iterations: 100000,
|
||||||
|
hash: "SHA-256",
|
||||||
|
},
|
||||||
|
master,
|
||||||
|
{ name: "AES-GCM", length: 256 },
|
||||||
|
true,
|
||||||
|
["wrapKey", "unwrapKey"],
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
throw new UnwrapKeyError("deriving", String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
const privKey = await crypto.subtle
|
||||||
|
.unwrapKey(
|
||||||
|
"pkcs8",
|
||||||
|
value,
|
||||||
|
unwrappingKey,
|
||||||
|
{
|
||||||
|
name: "AES-GCM",
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
rsaAlgorithm,
|
||||||
|
true,
|
||||||
|
["decrypt"],
|
||||||
|
)
|
||||||
|
.catch((e) => {
|
||||||
|
throw new UnwrapKeyError("unwrapping", String(e));
|
||||||
|
});
|
||||||
|
return privKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Steps = "starting" | "deriving" | "unwrapping";
|
||||||
|
export class UnwrapKeyError extends Error {
|
||||||
|
public step: Steps;
|
||||||
|
public cause: string;
|
||||||
|
constructor(step: Steps, cause: string) {
|
||||||
|
super(`Recovering private key failed on "${step}": ${cause}`);
|
||||||
|
this.step = step;
|
||||||
|
this.cause = cause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks like there is no easy way to do it with the Web Crypto API
|
||||||
|
*/
|
||||||
|
async function getPublicFromPrivate(key: CryptoKey): Promise<CryptoKey> {
|
||||||
|
const jwk = await crypto.subtle.exportKey("jwk", key).catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
|
||||||
|
delete jwk.d;
|
||||||
|
delete jwk.dp;
|
||||||
|
delete jwk.dq;
|
||||||
|
delete jwk.q;
|
||||||
|
delete jwk.qi;
|
||||||
|
jwk.key_ops = ["encrypt"];
|
||||||
|
|
||||||
|
return crypto.subtle
|
||||||
|
.importKey("jwk", jwk, rsaAlgorithm, true, ["encrypt"])
|
||||||
|
.catch((e) => {
|
||||||
|
throw new Error(String(e));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ab2str(buf: ArrayBuffer) {
|
||||||
|
return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)));
|
||||||
|
}
|
||||||
|
function str2ab(str: string) {
|
||||||
|
const buf = new ArrayBuffer(str.length);
|
||||||
|
const bufView = new Uint8Array(buf);
|
||||||
|
for (let i = 0, strLen = str.length; i < strLen; i++) {
|
||||||
|
bufView[i] = str.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCryptoPArameters(sessionId: string): {
|
||||||
|
salt: Uint8Array;
|
||||||
|
initVector: Uint8Array;
|
||||||
|
} {
|
||||||
|
const [saltId, vectorId] = sessionId.split("-");
|
||||||
|
return {
|
||||||
|
salt: decodeCrock(saltId),
|
||||||
|
initVector: decodeCrock(vectorId),
|
||||||
|
};
|
||||||
|
}
|
@ -41,10 +41,12 @@ export function FormProvider<T>({
|
|||||||
children,
|
children,
|
||||||
initialValue,
|
initialValue,
|
||||||
onUpdate,
|
onUpdate,
|
||||||
|
onSubmit,
|
||||||
computeFormState,
|
computeFormState,
|
||||||
}: {
|
}: {
|
||||||
initialValue?: Partial<T>;
|
initialValue?: Partial<T>;
|
||||||
onUpdate?: (v: Partial<T>) => void;
|
onUpdate?: (v: Partial<T>) => void;
|
||||||
|
onSubmit: (v: T) => void;
|
||||||
computeFormState?: (v: T) => FormState<T>;
|
computeFormState?: (v: T) => FormState<T>;
|
||||||
children: ComponentChildren;
|
children: ComponentChildren;
|
||||||
}): VNode {
|
}): VNode {
|
||||||
@ -58,7 +60,15 @@ export function FormProvider<T>({
|
|||||||
<FormContext.Provider
|
<FormContext.Provider
|
||||||
value={{ initialValue, value, onUpdate, computeFormState }}
|
value={{ initialValue, value, onUpdate, computeFormState }}
|
||||||
>
|
>
|
||||||
<form>{children}</form>
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
//@ts-ignore
|
||||||
|
onSubmit(value.current);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</form>
|
||||||
</FormContext.Provider>
|
</FormContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -23,7 +23,7 @@ interface StringConverter<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UIFormProps<T> {
|
export interface UIFormProps<T> {
|
||||||
name: string;
|
name: keyof T;
|
||||||
label: TranslatedString;
|
label: TranslatedString;
|
||||||
placeholder?: TranslatedString;
|
placeholder?: TranslatedString;
|
||||||
tooltip?: TranslatedString;
|
tooltip?: TranslatedString;
|
||||||
@ -181,7 +181,11 @@ function defaultFromString(v: string) {
|
|||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputLine<T>(props: { type: string } & UIFormProps<T>): VNode {
|
type InputType = "text" | "text-area" | "password" | "email";
|
||||||
|
|
||||||
|
export function InputLine<T>(
|
||||||
|
props: { type: InputType } & UIFormProps<T>,
|
||||||
|
): VNode {
|
||||||
const { name, placeholder, before, after, converter, type } = props;
|
const { name, placeholder, before, after, converter, type } = props;
|
||||||
const { value, onChange, state, isDirty } = useField(name);
|
const { value, onChange, state, isDirty } = useField(name);
|
||||||
|
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { VNode, h } from "preact";
|
import { VNode, h } from "preact";
|
||||||
import { InputLine, UIFormProps } from "./InputLine.js";
|
import { InputLine, UIFormProps } from "./InputLine.js";
|
||||||
|
|
||||||
export function InputText(props: UIFormProps<string>): VNode {
|
export function InputText<T>(props: UIFormProps<T>): VNode {
|
||||||
return <InputLine type="text" {...props} />;
|
return <InputLine type="text" {...props} />;
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,8 @@ import { InputFile } from "./InputFile.js";
|
|||||||
import { Caption } from "./Caption.js";
|
import { Caption } from "./Caption.js";
|
||||||
import { Group } from "./Group.js";
|
import { Group } from "./Group.js";
|
||||||
import { InputSelectOne } from "./InputSelectOne.js";
|
import { InputSelectOne } from "./InputSelectOne.js";
|
||||||
|
import { FormProvider } from "./FormProvider.js";
|
||||||
|
import { InputLine } from "./InputLine.js";
|
||||||
|
|
||||||
export type DoubleColumnForm = DoubleColumnFormSection[];
|
export type DoubleColumnForm = DoubleColumnFormSection[];
|
||||||
|
|
||||||
@ -94,3 +96,14 @@ export function RenderAllFieldsByUiConfig({
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormSet<T> = {
|
||||||
|
Provider: typeof FormProvider<T>;
|
||||||
|
InputLine: typeof InputLine<T>;
|
||||||
|
};
|
||||||
|
export function createNewForm<T>(): FormSet<T> {
|
||||||
|
return {
|
||||||
|
Provider: FormProvider,
|
||||||
|
InputLine: InputLine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
@ -1,116 +1,265 @@
|
|||||||
import { useLocalStorage } from "@gnu-taler/web-util/browser";
|
import { TranslatedString } from "@gnu-taler/taler-util";
|
||||||
import { h } from "preact";
|
import {
|
||||||
|
notifyError,
|
||||||
|
notifyInfo,
|
||||||
|
useLocalStorage,
|
||||||
|
useMemoryStorage,
|
||||||
|
} from "@gnu-taler/web-util/browser";
|
||||||
|
import { VNode, h } from "preact";
|
||||||
import { useEffect, useState } from "preact/hooks";
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
import {
|
||||||
|
UnwrapKeyError,
|
||||||
|
createNewAccount,
|
||||||
|
createNewSessionId,
|
||||||
|
unlockAccount,
|
||||||
|
} from "../account.js";
|
||||||
|
import { createNewForm } from "../handlers/forms.js";
|
||||||
|
|
||||||
const oldKey =
|
|
||||||
"MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQDPQVq8F0Ce6kTXKQ5Ea2fZRoap6poFYs0FOln8o8+ehGI8rDdMBzNU3pLIlOMKs/vKvhDNMG4m4xxb92wDbvefDxkxaEkbRSZnRiJd4MIbh8Lx8zvFbLp03rkXu9KPN8IprKOXxgN7xbxm0KKcu03rtqLiOvC1gMqja2LMIPCi32nyNneduszHZ57d+CqIKZdVnaqAcXOSMAQsVoEq2joBOeIaSAnIJHg+T8HQ+VcLV8Y722jhX/bH84IyEMup9e7mhgVFnHgINc77c6TONH8H+dHlXCQ+hMPGw9wM+wgpJgIDzrhIN+QSjn283EOXD6z6dpiWBdEYfJRLHwEWk8wNAgMBAAECggEAB/anZrMasQsoXP9qBG1Uvq+r4fXZODFtK5vBNGi+RAWAhCX2iU3SMPB3wbby0wj1DlESR91qBhrTjqG+/TzIzUxLuARyoVZysiTVkjeIzdJVcRgwU5bTbUUs5da6MaA/WNGWMZvoALFUMBEpMQ4uDCC8OSbG8/prDtoZSuWjHrxBhsqSyIoJ3Q0iPQxPT0ShC9d5T56QuhsRQeRIWhQVtFlytXl1lqEbqljhIEOzkvS5QOcXcS3OBo/Nvdit+vi9kkLuiP8z2p6WAiVZCgCXfffNH3EEbQG/BEpIOynkchiDy1L31mFRFk1oYJRs9xD8+oF/N75GhlmYO7IbxeHw0wKBgQDnYZWjGlRM2oHpeiPSII5m9rC7qohO0ImxqifYZPp47vdRMbBWrdbxX68SqdzGfSzXcDPLfBAObG4QR8Xol1LMNJUT9og9pERZHgob+yWkTd68lLSdxfCJEKRJaDmD8dHgSrBYe86ADUeAj+fC4dycYXH//fwed1gt/G8iXtdU9wKBgQDlTp9752+tEh9fMlUdINbZXmGbjHBrZMTnAYJI509iJLIvJvYroU5TvRMsp+rACDc2Zy2nbsaCM5Xzd5wUxRBvF+PiBCFoi7c/EBaLCtb9+vyXtHAIHtzHeYUP/1cq7MOdTwrWvZqzIoW6xm7L9HRX/5i+n+rVUSxnzYIxgTlaGwKBgQC0INgpXbn7CrDQXnG8h/PUXIBB2QS8tsQ7N8hFQndr5j1LTG+HS1ZmGqNk2DAzpgdewM7RvweQ8wDMU9PSutuOdfEI1YhC1LsQ1b3xApfPTX/1N59UpGAZlIcRTr5X5c4J2ptmhxu/vJbJkz5ODR997q6dJ9E6tpZDVp3+F+9zCQKBgQCrp+OzuVjcUoixltgoagDrz7951fQCMPlFhNenA6FlctsAeUYm+yXLgersrvcIsh3C2BJRGJf5t+w0ygFJewwGXff1pensfUq8Jqr5gy/WCSE135lOOuxDVzDI/Pif5YW6KQWQI3e/ScSaQRmIDINbrLcHXGdLMOzw9+LSdE4eqQKBgQDe86MfzwMLPoDH07WC09dCcoIUSYMThYrFwUK3qgEiYaJMZJvdAIwr12szVwVRYIX4wHBObFsQZLTaY5+O/REnze6Q1AQa2H6eH1TalC1r6jBS5/LhIrVWl/0VSdsUIe41tc8xPDWrm9hmLeJLZk+xb5/hAm3REsDM1Iif9O7zzg==";
|
|
||||||
export function Officer() {
|
export function Officer() {
|
||||||
const storage = useLocalStorage("officer");
|
const password = useMemoryStorage("password");
|
||||||
const [keys, setKeys] = useState({ priv: "", pub: "" });
|
const session = useLocalStorage("session");
|
||||||
|
const officer = useLocalStorage("officer");
|
||||||
|
const [keys, setKeys] = useState({ accountId: "", pub: "" });
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPreviousSession(oldKey).then((keys) =>
|
if (
|
||||||
setKeys(keys ?? { priv: "", pub: "" }),
|
officer.value === undefined ||
|
||||||
);
|
session.value === undefined ||
|
||||||
// generateNewId().then((keys) => setKeys(keys));
|
password.value === undefined
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unlockAccount(session.value, officer.value, password.value)
|
||||||
|
.then((keys) => setKeys(keys ?? { accountId: "", pub: "" }))
|
||||||
|
.catch((e) => {
|
||||||
|
if (e instanceof UnwrapKeyError) {
|
||||||
|
console.log(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [officer.value, session.value, password.value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!session.value) {
|
||||||
|
session.update(createNewSessionId());
|
||||||
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
console.log(keys.pub);
|
const { value: sessionId } = session;
|
||||||
console.log(keys.priv);
|
if (!sessionId) {
|
||||||
|
return <div>loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (officer.value === undefined) {
|
||||||
|
return (
|
||||||
|
<CreateAccount
|
||||||
|
sessionId={sessionId}
|
||||||
|
onNewAccount={(id) => {
|
||||||
|
password.reset();
|
||||||
|
officer.update(id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("pwd", password.value);
|
||||||
|
if (password.value === undefined) {
|
||||||
|
return (
|
||||||
|
<UnlockAccount
|
||||||
|
sessionId={sessionId}
|
||||||
|
accountId={officer.value}
|
||||||
|
onAccountUnlocked={(pwd) => {
|
||||||
|
password.update(pwd);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div>Officer</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>
|
||||||
|
<p class="mt-6 leading-8 text-gray-700 break-all">
|
||||||
-----BEGIN PUBLIC KEY-----
|
-----BEGIN PUBLIC KEY-----
|
||||||
<p class="mt-6 leading-8 text-gray-700 break-all">{keys.pub}</p>
|
<div>{keys.pub}</div>
|
||||||
-----END PUBLIC KEY-----
|
-----END PUBLIC KEY-----
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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 ">
|
||||||
Private key
|
Private key
|
||||||
</h1>
|
</h1>
|
||||||
<div>
|
<div>
|
||||||
|
<p class="mt-6 leading-8 text-gray-700 break-all">
|
||||||
-----BEGIN PRIVATE KEY-----
|
-----BEGIN PRIVATE KEY-----
|
||||||
<p class="mt-6 leading-8 text-gray-700 break-all">{keys.priv}</p>
|
<div>{keys.accountId}</div>
|
||||||
-----END PRIVATE KEY-----
|
-----END PRIVATE KEY-----
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rsaAlgorithm: RsaHashedKeyGenParams = {
|
function CreateAccount({
|
||||||
name: "RSA-OAEP",
|
sessionId,
|
||||||
modulusLength: 2048,
|
onNewAccount,
|
||||||
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
}: {
|
||||||
hash: "SHA-256",
|
sessionId: string;
|
||||||
|
onNewAccount: (accountId: string) => void;
|
||||||
|
}): VNode {
|
||||||
|
const Form = createNewForm<{
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
Create account
|
||||||
|
</h2>
|
||||||
|
</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
|
||||||
|
onSubmit={async (v) => {
|
||||||
|
const keys = await createNewAccount(sessionId, v.password);
|
||||||
|
onNewAccount(keys.accountId);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="mb-4">
|
||||||
|
<Form.InputLine
|
||||||
|
label={"Email" as TranslatedString}
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<Form.InputLine
|
||||||
|
label={"Password" as TranslatedString}
|
||||||
|
name="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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"
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Form.Provider>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function UnlockAccount({
|
||||||
|
sessionId,
|
||||||
|
accountId,
|
||||||
|
onAccountUnlocked,
|
||||||
|
}: {
|
||||||
|
sessionId: string;
|
||||||
|
accountId: string;
|
||||||
|
onAccountUnlocked: (password: string) => void;
|
||||||
|
}): VNode {
|
||||||
|
const Form = createNewForm<{
|
||||||
|
sessionId: string;
|
||||||
|
accountId: string;
|
||||||
|
password: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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
|
||||||
|
</h2>
|
||||||
|
</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);
|
||||||
|
|
||||||
async function generateNewId() {
|
onAccountUnlocked(v.password ?? "");
|
||||||
const key = await crypto.subtle.generateKey(rsaAlgorithm, true, [
|
notifyInfo("Account unlocked" as TranslatedString);
|
||||||
"encrypt",
|
} catch (e) {
|
||||||
"decrypt",
|
if (e instanceof UnwrapKeyError) {
|
||||||
]);
|
notifyError(
|
||||||
|
"Could not unlock account" as any,
|
||||||
if (key instanceof CryptoKey) {
|
e.message as any,
|
||||||
throw Error("unexpected key without pair");
|
);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
}
|
}
|
||||||
const { privateKey, publicKey } = key;
|
|
||||||
const privRaw = await crypto.subtle.exportKey("pkcs8", privateKey);
|
|
||||||
|
|
||||||
const pubRaw = await crypto.subtle.exportKey("spki", publicKey);
|
|
||||||
|
|
||||||
const priv = btoa(ab2str(privRaw));
|
|
||||||
|
|
||||||
const pub = btoa(ab2str(pubRaw));
|
|
||||||
return { priv, pub };
|
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
async function loadPreviousSession(priv: string) {
|
<div class="mb-4">
|
||||||
const key = str2ab(window.atob(priv));
|
<Form.InputLine
|
||||||
const privateKey = await window.crypto.subtle
|
label={"Password" as TranslatedString}
|
||||||
.importKey("pkcs8", key, rsaAlgorithm, true, ["decrypt"])
|
name="password"
|
||||||
.catch(throwErrorWithStack);
|
type="password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
if (!privateKey) return undefined;
|
<div class="mt-8">
|
||||||
|
<button
|
||||||
// export private key to JWK
|
type="submit"
|
||||||
const jwk = await crypto.subtle
|
class="flex w-full justify-center rounded-md bg-indigo-600 px-3 py-1.5 text-sm font-semibold leading-6 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"
|
||||||
.exportKey("jwk", privateKey)
|
>
|
||||||
.catch(throwErrorWithStack);
|
Unlock
|
||||||
|
</button>
|
||||||
// remove private data from JWK
|
</div>
|
||||||
delete jwk.d;
|
</Form.Provider>
|
||||||
delete jwk.dp;
|
</div>
|
||||||
delete jwk.dq;
|
</div>
|
||||||
delete jwk.q;
|
</div>
|
||||||
delete jwk.qi;
|
);
|
||||||
jwk.key_ops = ["encrypt"];
|
|
||||||
|
|
||||||
const publicKey = await crypto.subtle
|
|
||||||
.importKey("jwk", jwk, rsaAlgorithm, true, ["encrypt"])
|
|
||||||
.catch(throwErrorWithStack);
|
|
||||||
|
|
||||||
const pubRaw = await crypto.subtle
|
|
||||||
.exportKey("spki", publicKey)
|
|
||||||
.catch(throwErrorWithStack);
|
|
||||||
|
|
||||||
const pub = btoa(ab2str(pubRaw));
|
|
||||||
|
|
||||||
return { priv, pub };
|
|
||||||
}
|
|
||||||
|
|
||||||
function ab2str(buf: ArrayBuffer) {
|
|
||||||
return String.fromCharCode.apply(null, Array.from(new Uint8Array(buf)));
|
|
||||||
}
|
|
||||||
function str2ab(str: string) {
|
|
||||||
const buf = new ArrayBuffer(str.length);
|
|
||||||
const bufView = new Uint8Array(buf);
|
|
||||||
for (let i = 0, strLen = str.length; i < strLen; i++) {
|
|
||||||
bufView[i] = str.charCodeAt(i);
|
|
||||||
}
|
|
||||||
return buf;
|
|
||||||
}
|
|
||||||
function throwErrorWithStack(e: Error): never {
|
|
||||||
throw new Error(e.message);
|
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user