diff --git a/packages/exchange-backoffice-ui/src/account.ts b/packages/exchange-backoffice-ui/src/account.ts index 05f0f8984..bd3c2003e 100644 --- a/packages/exchange-backoffice-ui/src/account.ts +++ b/packages/exchange-backoffice-ui/src/account.ts @@ -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 { - const { eddsaPriv } = createEddsaKeyPair(); +): Promise { + 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 { diff --git a/packages/exchange-backoffice-ui/src/forms/902_1e.ts b/packages/exchange-backoffice-ui/src/forms/902_1e.ts index 04952a985..654085443 100644 --- a/packages/exchange-backoffice-ui/src/forms/902_1e.ts +++ b/packages/exchange-backoffice-ui/src/forms/902_1e.ts @@ -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 => ({ diff --git a/packages/exchange-backoffice-ui/src/forms/902_4e.ts b/packages/exchange-backoffice-ui/src/forms/902_4e.ts index 15ad17144..f77a2f63a 100644 --- a/packages/exchange-backoffice-ui/src/forms/902_4e.ts +++ b/packages/exchange-backoffice-ui/src/forms/902_4e.ts @@ -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 => ({ diff --git a/packages/exchange-backoffice-ui/src/forms/simplest.ts b/packages/exchange-backoffice-ui/src/forms/simplest.ts index 5da01961b..7eda03fef 100644 --- a/packages/exchange-backoffice-ui/src/forms/simplest.ts +++ b/packages/exchange-backoffice-ui/src/forms/simplest.ts @@ -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"; diff --git a/packages/exchange-backoffice-ui/src/hooks/useOfficer.ts b/packages/exchange-backoffice-ui/src/hooks/useOfficer.ts new file mode 100644 index 000000000..2ed375846 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/hooks/useOfficer.ts @@ -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; + +export const codecForOfficer = (): Codec => + buildCodecForObject() + .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; +} +interface OfficerLocked { + state: "locked"; + forget: () => void; + tryUnlock: (password: string) => Promise; +} +interface OfficerReady { + state: "ready"; + account: Account; + forget: () => void; + lock: () => void; +} + +const OFFICER_KEY = buildStorageKey("officer", codecForOfficer()); +const ACCOUNT_KEY = buildStorageKey("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(); + }, + }; +} diff --git a/packages/exchange-backoffice-ui/src/pages.ts b/packages/exchange-backoffice-ui/src/pages.ts index 2b13ce585..18fb7a158 100644 --- a/packages/exchange-backoffice-ui/src/pages.ts +++ b/packages/exchange-backoffice-ui/src/pages.ts @@ -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 }> = { diff --git a/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx b/packages/exchange-backoffice-ui/src/pages/CaseDetails.tsx similarity index 99% rename from packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx rename to packages/exchange-backoffice-ui/src/pages/CaseDetails.tsx index b252d2ab0..e5fb8eaba 100644 --- a/packages/exchange-backoffice-ui/src/pages/AccountDetails.tsx +++ b/packages/exchange-backoffice-ui/src/pages/CaseDetails.tsx @@ -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, diff --git a/packages/exchange-backoffice-ui/src/pages/Cases.tsx b/packages/exchange-backoffice-ui/src/pages/Cases.tsx index 1983769ed..28b9d2a88 100644 --- a/packages/exchange-backoffice-ui/src/pages/Cases.tsx +++ b/packages/exchange-backoffice-ui/src/pages/Cases.tsx @@ -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 ; + } const form = createNewForm<{ state: AmlState; }>(); diff --git a/packages/exchange-backoffice-ui/src/pages/CreateAccount.tsx b/packages/exchange-backoffice-ui/src/pages/CreateAccount.tsx new file mode 100644 index 000000000..41a1d20ff --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/CreateAccount.tsx @@ -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 ( +
+
+

+ Create account +

+
+ +
+
+ { + 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); + }} + > +
+ +
+
+ +
+ +
+ +
+
+
+
+
+ ); +} diff --git a/packages/exchange-backoffice-ui/src/pages/HandleAccountNotReady.tsx b/packages/exchange-backoffice-ui/src/pages/HandleAccountNotReady.tsx new file mode 100644 index 000000000..b0c430875 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/HandleAccountNotReady.tsx @@ -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 ( + { + officer.create(password); + }} + /> + ); + } + + if (officer.state === "locked") { + return ( + { + officer.tryUnlock(pwd); + }} + /> + ); + } + throw Error(`unexpected account state ${(officer as any).state}`); +} diff --git a/packages/exchange-backoffice-ui/src/pages/Officer.tsx b/packages/exchange-backoffice-ui/src/pages/Officer.tsx index 40ec33018..5320369e4 100644 --- a/packages/exchange-backoffice-ui/src/pages/Officer.tsx +++ b/packages/exchange-backoffice-ui/src/pages/Officer.tsx @@ -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; - -export const codecForOfficer = (): Codec => - buildCodecForObject() - .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(); - - 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 ( - { - password.update(pwd); - officer.update({ account, when: AbsoluteTime.now() }); - }} - /> - ); - } - - if (password.value === undefined) { - return ( - { - password.update(pwd); - }} - /> - ); + const officer = useOfficer(); + if (officer.state !== "ready") { + return ; } return ( @@ -86,12 +14,12 @@ export function Officer() { Public key
-

{keys?.accountId}

+

{officer.account.accountId}

{ - 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() { - - - - - - ); -} - -function UnlockAccount({ - lockedAccount, - onAccountUnlocked, -}: { - lockedAccount: LockedAccount; - onAccountUnlocked: (password: string) => void; -}): VNode { - const Form = createNewForm<{ - password: string; - }>(); - - return ( -

- ); -} diff --git a/packages/exchange-backoffice-ui/src/pages/UnlockAccount.tsx b/packages/exchange-backoffice-ui/src/pages/UnlockAccount.tsx new file mode 100644 index 000000000..941e28627 --- /dev/null +++ b/packages/exchange-backoffice-ui/src/pages/UnlockAccount.tsx @@ -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 ( +
+
+

+ Account locked +

+

+ Your account is normally locked anytime you reload. To unlock type + your password again. +

+
+ +
+
+ { + 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; + } + } + }} + > +
+ +
+ +
+ +
+
+
+
+
+ ); +}