account as hook

This commit is contained in:
Sebastian 2023-05-26 16:52:30 -03:00
parent be27647ff7
commit 69b66e715e
No known key found for this signature in database
GPG Key ID: 173909D1A5F66069
12 changed files with 321 additions and 250 deletions

View File

@ -16,7 +16,7 @@ export interface Account {
}
/**
* Restore previous session and unlock account
* Restore previous session and unlock account with password
*
* @param salt string from which crypto params will be derived
* @param key secured private key
@ -55,7 +55,7 @@ declare const opaque_SigningKey: unique symbol;
export type SigningKey = Uint8Array & { [opaque_SigningKey]: true };
/**
* Create new account (secured private key) under session
* Create new account (secured private key)
* secured with the given password
*
* @param sessionId
@ -64,8 +64,8 @@ export type SigningKey = Uint8Array & { [opaque_SigningKey]: true };
*/
export async function createNewAccount(
password: string,
): Promise<LockedAccount> {
const { eddsaPriv } = createEddsaKeyPair();
): Promise<Account & { safe: LockedAccount }> {
const { eddsaPriv, eddsaPub } = createEddsaKeyPair();
const key = stringToBytes(password);
@ -76,9 +76,11 @@ export async function createNewAccount(
password,
);
const protectedPriv = encodeCrock(protectedPrivKey);
const signingKey = eddsaPriv as SigningKey;
const accountId = encodeCrock(eddsaPub) as AccountId;
const safe = encodeCrock(protectedPrivKey) as LockedAccount;
return protectedPriv as LockedAccount;
return { accountId, signingKey, safe };
}
export class UnwrapKeyError extends Error {

View File

@ -8,7 +8,7 @@ import { FlexibleForm, languageList } from "./index.js";
import { FormState } from "../handlers/FormProvider.js";
import { State } from "../pages/AntiMoneyLaunderingForm.js";
import { AmlState } from "../types.js";
import { amlStateConverter } from "../pages/AccountDetails.js";
import { amlStateConverter } from "../pages/CaseDetails.js";
import { Simplest, resolutionSection } from "./simplest.js";
export const v1 = (current: State): FlexibleForm<Form902_1.Form> => ({

View File

@ -11,7 +11,7 @@ import { h as create } from "preact";
import { ChevronRightIcon } from "@heroicons/react/24/solid";
import { State } from "../pages/AntiMoneyLaunderingForm.js";
import { AmlState } from "../types.js";
import { amlStateConverter } from "../pages/AccountDetails.js";
import { amlStateConverter } from "../pages/CaseDetails.js";
import { Simplest, resolutionSection } from "./simplest.js";
export const v1 = (current: State): FlexibleForm<Form902_4.Form> => ({

View File

@ -7,7 +7,7 @@ import {
import { FormState } from "../handlers/FormProvider.js";
import { FlexibleForm } from "./index.js";
import { AmlState } from "../types.js";
import { amlStateConverter } from "../pages/AccountDetails.js";
import { amlStateConverter } from "../pages/CaseDetails.js";
import { State } from "../pages/AntiMoneyLaunderingForm.js";
import { DoubleColumnFormSection, UIFormField } from "../handlers/forms.js";

View File

@ -0,0 +1,100 @@
import {
AbsoluteTime,
Codec,
buildCodecForObject,
codecForAbsoluteTime,
codecForString,
} from "@gnu-taler/taler-util";
import {
Account,
LockedAccount,
createNewAccount,
unlockAccount,
} from "../account.js";
import {
buildStorageKey,
useLocalStorage,
useMemoryStorage,
} from "@gnu-taler/web-util/browser";
export interface Officer {
account: LockedAccount;
when: AbsoluteTime;
}
const codecForLockedAccount = codecForString() as Codec<LockedAccount>;
export const codecForOfficer = (): Codec<Officer> =>
buildCodecForObject<Officer>()
.property("account", codecForLockedAccount) // FIXME
.property("when", codecForAbsoluteTime) // FIXME
.build("Officer");
export type OfficerState = OfficerNotReady | OfficerReady;
export type OfficerNotReady = OfficerNotFound | OfficerLocked;
interface OfficerNotFound {
state: "not-found";
create: (password: string) => Promise<void>;
}
interface OfficerLocked {
state: "locked";
forget: () => void;
tryUnlock: (password: string) => Promise<void>;
}
interface OfficerReady {
state: "ready";
account: Account;
forget: () => void;
lock: () => void;
}
const OFFICER_KEY = buildStorageKey("officer", codecForOfficer());
const ACCOUNT_KEY = buildStorageKey<Account>("account");
export function useOfficer(): OfficerState {
const accountStorage = useMemoryStorage(ACCOUNT_KEY);
const officerStorage = useLocalStorage(OFFICER_KEY);
const officer = officerStorage.value;
const account = accountStorage.value;
if (officer === undefined) {
return {
state: "not-found",
create: async (pwd: string) => {
const { accountId, safe, signingKey } = await createNewAccount(pwd);
officerStorage.update({
account: safe,
when: AbsoluteTime.now(),
});
accountStorage.update({ accountId, signingKey });
},
};
}
if (account === undefined) {
return {
state: "locked",
forget: () => {
officerStorage.reset();
},
tryUnlock: async (pwd: string) => {
const ac = await unlockAccount(officer.account, pwd);
accountStorage.update(ac);
},
};
}
return {
state: "ready",
account: account,
lock: () => {
accountStorage.reset();
},
forget: () => {
officerStorage.reset();
accountStorage.reset();
},
};
}

View File

@ -5,7 +5,7 @@ import { Welcome } from "./pages/Welcome.js";
import { PageEntry, pageDefinition } from "./route.js";
import { Officer } from "./pages/Officer.js";
import { Cases } from "./pages/Cases.js";
import { AccountDetails } from "./pages/AccountDetails.js";
import { CaseDetails } from "./pages/CaseDetails.js";
import { NewFormEntry } from "./pages/NewFormEntry.js";
const home: PageEntry = {
@ -18,7 +18,7 @@ const cases: PageEntry = {
};
const account: PageEntry<{ account?: string }> = {
url: pageDefinition("#/account/:account"),
view: AccountDetails,
view: CaseDetails,
};
const newFormEntry: PageEntry<{ account?: string; type?: string }> = {

View File

@ -141,7 +141,7 @@ function getEventsFromAmlHistory(
return ae.concat(ke).sort(selectSooner);
}
export function AccountDetails({ account }: { account?: string }) {
export function CaseDetails({ account }: { account?: string }) {
const events = getEventsFromAmlHistory(
response.aml_history,
response.kyc_attributes,

View File

@ -4,8 +4,10 @@ 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 { amlStateConverter as amlStateConverter } from "./CaseDetails.js";
import { useState } from "preact/hooks";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
import { useOfficer } from "../hooks/useOfficer.js";
const response: AmlRecords = {
records: [
@ -61,6 +63,10 @@ function doFilter(
}
export function Cases() {
const officer = useOfficer();
if (officer.state !== "ready") {
return <HandleAccountNotReady officer={officer} />;
}
const form = createNewForm<{
state: AmlState;
}>();

View File

@ -0,0 +1,89 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { useTranslationContext } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { createNewForm } from "../handlers/forms.js";
export function CreateAccount({
onNewAccount,
}: {
onNewAccount: (password: string) => void;
}): VNode {
const { i18n } = useTranslationContext();
const Form = createNewForm<{
password: string;
repeat: 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
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) => {
onNewAccount(v.password);
}}
>
<div class="mb-4">
<Form.InputLine
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={"Repeat password" as TranslatedString}
name="repeat"
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>
);
}

View File

@ -0,0 +1,31 @@
import { VNode, h } from "preact";
import { OfficerNotReady } from "../hooks/useOfficer.js";
import { CreateAccount } from "./CreateAccount.js";
import { UnlockAccount } from "./UnlockAccount.js";
export function HandleAccountNotReady({
officer,
}: {
officer: OfficerNotReady;
}): VNode {
if (officer.state === "not-found") {
return (
<CreateAccount
onNewAccount={(password) => {
officer.create(password);
}}
/>
);
}
if (officer.state === "locked") {
return (
<UnlockAccount
onAccountUnlocked={(pwd) => {
officer.tryUnlock(pwd);
}}
/>
);
}
throw Error(`unexpected account state ${(officer as any).state}`);
}

View File

@ -1,83 +1,11 @@
import {
AbsoluteTime,
Codec,
TranslatedString,
buildCodecForObject,
codecForAbsoluteTime,
codecForString,
} from "@gnu-taler/taler-util";
import {
notifyError,
notifyInfo,
useLocalStorage,
useMemoryStorage,
useTranslationContext,
} from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { useEffect, useState } from "preact/hooks";
import {
Account,
LockedAccount,
UnwrapKeyError,
createNewAccount,
unlockAccount,
} from "../account.js";
import { createNewForm } from "../handlers/forms.js";
export interface Officer {
account: LockedAccount;
when: AbsoluteTime;
}
const codecForLockedAccount = codecForString() as Codec<LockedAccount>;
export const codecForOfficer = (): Codec<Officer> =>
buildCodecForObject<Officer>()
.property("account", codecForLockedAccount) // FIXME
.property("when", codecForAbsoluteTime) // FIXME
.build("Officer");
import { Fragment, h } from "preact";
import { useOfficer } from "../hooks/useOfficer.js";
import { HandleAccountNotReady } from "./HandleAccountNotReady.js";
export function Officer() {
const password = useMemoryStorage("password");
const officer = useLocalStorage("officer", {
codec: codecForOfficer(),
});
const [keys, setKeys] = useState<Account>();
useEffect(() => {
if (officer.value === undefined || password.value === undefined) {
return;
}
unlockAccount(officer.value.account, password.value)
.then((keys) => setKeys(keys ?? { accountId: "", pub: "" }))
.catch((e) => {
if (e instanceof UnwrapKeyError) {
console.log(e);
}
});
}, [officer.value, password.value]);
if (officer.value === undefined || !officer.value.account) {
return (
<CreateAccount
onNewAccount={(account, pwd) => {
password.update(pwd);
officer.update({ account, when: AbsoluteTime.now() });
}}
/>
);
}
if (password.value === undefined) {
return (
<UnlockAccount
lockedAccount={officer.value.account}
onAccountUnlocked={(pwd) => {
password.update(pwd);
}}
/>
);
const officer = useOfficer();
if (officer.state !== "ready") {
return <HandleAccountNotReady officer={officer} />;
}
return (
@ -86,12 +14,12 @@ export function Officer() {
Public key
</h1>
<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>
<p class="mt-6 font-mono break-all">{officer.account.accountId}</p>
</div>
<p>
<a
href={`mailto:aml@exchange.taler.net?body=${encodeURIComponent(
`I want my AML account\n\n\nPubKey: ${keys?.accountId}`,
`I want my AML account\n\n\nPubKey: ${officer.account.accountId}`,
)}`}
target="_blank"
rel="noreferrer"
@ -104,7 +32,7 @@ export function Officer() {
<button
type="button"
onClick={() => {
password.reset();
officer.lock();
}}
class="m-4 block rounded-md border-0 bg-gray-200 px-3 py-2 text-center text-sm text-black shadow-sm "
>
@ -115,7 +43,7 @@ export function Officer() {
<button
type="button"
onClick={() => {
officer.reset();
officer.forget();
}}
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 "
>
@ -125,158 +53,3 @@ export function Officer() {
</div>
);
}
function CreateAccount({
onNewAccount,
}: {
onNewAccount: (account: LockedAccount, password: string) => void;
}): VNode {
const { i18n } = useTranslationContext();
const Form = createNewForm<{
password: string;
repeat: 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
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 account = await createNewAccount(v.password);
onNewAccount(account, v.password);
}}
>
<div class="mb-4">
<Form.InputLine
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={"Repeat password" as TranslatedString}
name="repeat"
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({
lockedAccount,
onAccountUnlocked,
}: {
lockedAccount: LockedAccount;
onAccountUnlocked: (password: string) => void;
}): VNode {
const Form = createNewForm<{
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">
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
onSubmit={async (v) => {
try {
// test login
await unlockAccount(lockedAccount, v.password);
onAccountUnlocked(v.password ?? "");
notifyInfo("Account unlocked" as TranslatedString);
} catch (e) {
if (e instanceof UnwrapKeyError) {
notifyError(
"Could not unlock account" as any,
e.message as any,
);
} else {
throw e;
}
}
}}
>
<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"
>
Unlock
</button>
</div>
</Form.Provider>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,70 @@
import { TranslatedString } from "@gnu-taler/taler-util";
import { notifyError, notifyInfo } from "@gnu-taler/web-util/browser";
import { VNode, h } from "preact";
import { UnwrapKeyError } from "../account.js";
import { createNewForm } from "../handlers/forms.js";
export function UnlockAccount({
onAccountUnlocked,
}: {
onAccountUnlocked: (password: string) => void;
}): VNode {
const Form = createNewForm<{
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">
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
onSubmit={async (v) => {
try {
await onAccountUnlocked(v.password);
notifyInfo("Account unlocked" as TranslatedString);
} catch (e) {
if (e instanceof UnwrapKeyError) {
notifyError(
"Could not unlock account" as any,
e.message as any,
);
} else {
throw e;
}
}
}}
>
<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"
>
Unlock
</button>
</div>
</Form.Provider>
</div>
</div>
</div>
);
}