diff options
author | Florian Dold <florian@dold.me> | 2021-10-13 10:48:25 +0200 |
---|---|---|
committer | Florian Dold <florian@dold.me> | 2021-10-13 10:49:20 +0200 |
commit | 0f1ef7eca1f1ab3c5a1787b19a6caec13fb30dec (patch) | |
tree | ae634d8a89388169d5093807fb9bdebf704ff67e /packages | |
parent | b8d03b6b2aef630c0fafd7f6ab0fe317abfe1d93 (diff) | |
download | wallet-core-0f1ef7eca1f1ab3c5a1787b19a6caec13fb30dec.tar.xz |
anastasis-webui: finish backup flow
Diffstat (limited to 'packages')
-rw-r--r-- | packages/anastasis-webui/package.json | 1 | ||||
-rw-r--r-- | packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts | 185 | ||||
-rw-r--r-- | packages/anastasis-webui/src/routes/home/index.tsx | 518 | ||||
-rw-r--r-- | packages/anastasis-webui/src/routes/home/style.css | 25 |
4 files changed, 658 insertions, 71 deletions
diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index ddbd9ef20..fe332be03 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -21,6 +21,7 @@ ] }, "dependencies": { + "@gnu-taler/taler-util": "workspace:^0.8.3", "preact": "^10.3.1", "preact-render-to-string": "^5.1.4", "preact-router": "^3.2.1" diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index 30bab96d1..d578d1418 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -1,6 +1,58 @@ import { useState } from "preact/hooks"; -type ReducerState = any; +export type ReducerState = + | ReducerStateBackup + | ReducerStateRecovery + | ReducerStateError; + +export interface ReducerStateBackup { + recovery_state: undefined; + backup_state: BackupStates; + code: undefined; + continents: any; + countries: any; + authentication_providers: any; + authentication_methods?: AuthMethod[]; + required_attributes: any; + secret_name?: string; + policies?: { + methods: { + authentication_method: number; + provider: string; + }[]; + }[]; + success_details: { + [provider_url: string]: { + policy_version: number; + }; + }; + payments?: string[]; + policy_payment_requests?: { + payto: string; + provider: string; + }[]; +} + +export interface AuthMethod { + type: string; + instructions: string; + challenge: string; +} + +export interface ReducerStateRecovery { + backup_state: undefined; + recovery_state: RecoveryStates; + code: undefined; + + continents: any; + countries: any; +} + +export interface ReducerStateError { + backup_state: undefined; + recovery_state: undefined; + code: number; +} interface AnastasisState { reducerState: ReducerState | undefined; @@ -10,6 +62,13 @@ interface AnastasisState { export enum BackupStates { ContinentSelecting = "CONTINENT_SELECTING", CountrySelecting = "COUNTRY_SELECTING", + UserAttributesCollecting = "USER_ATTRIBUTES_COLLECTING", + AuthenticationsEditing = "AUTHENTICATIONS_EDITING", + PoliciesReviewing = "POLICIES_REVIEWING", + SecretEditing = "SECRET_EDITING", + TruthsPaying = "TRUTHS_PAYING", + PoliciesPaying = "POLICIES_PAYING", + BackupFinished = "BACKUP_FINISHED", } export enum RecoveryStates { @@ -49,20 +108,62 @@ async function reduceState( return resp.json(); } +export interface ReducerTransactionHandle { + transactionState: ReducerState; + transition(action: string, args: any): Promise<ReducerState>; +} + export interface AnastasisReducerApi { - currentReducerState: ReducerState; + currentReducerState: ReducerState | undefined; currentError: any; + dismissError: () => void; startBackup: () => void; startRecover: () => void; + reset: () => void; back: () => void; transition(action: string, args: any): void; + /** + * Run multiple reducer steps in a transaction without + * affecting the UI-visible transition state in-between. + */ + runTransaction(f: (h: ReducerTransactionHandle) => Promise<void>): void; +} + +function restoreState(): any { + let state: any; + try { + let s = localStorage.getItem("anastasisReducerState"); + if (s === "undefined") { + state = undefined; + } else if (s) { + console.log("restoring state from", s); + state = JSON.parse(s); + } + } catch (e) { + console.log(e); + } + return state ?? undefined; } export function useAnastasisReducer(): AnastasisReducerApi { - const [anastasisState, setAnastasisState] = useState<AnastasisState>({ - reducerState: undefined, - currentError: undefined, - }); + const [anastasisState, setAnastasisStateInternal] = useState<AnastasisState>( + () => ({ + reducerState: restoreState(), + currentError: undefined, + }), + ); + + const setAnastasisState = (newState: AnastasisState) => { + try { + localStorage.setItem( + "anastasisReducerState", + JSON.stringify(newState.reducerState), + ); + } catch (e) { + console.log(e); + } + setAnastasisStateInternal(newState); + }; async function doTransition(action: string, args: any) { console.log("reducing with", action, args); @@ -102,30 +203,74 @@ export function useAnastasisReducer(): AnastasisReducerApi { doTransition(action, args); }, back() { + const reducerState = anastasisState.reducerState; + if (!reducerState) { + return; + } if ( - anastasisState.reducerState.backup_state === - BackupStates.ContinentSelecting || - anastasisState.reducerState.recovery_state === - RecoveryStates.ContinentSelecting + reducerState.backup_state === BackupStates.ContinentSelecting || + reducerState.recovery_state === RecoveryStates.ContinentSelecting ) { setAnastasisState({ ...anastasisState, currentError: undefined, reducerState: undefined, }); - } else if ( - anastasisState.reducerState.backup_state === - BackupStates.CountrySelecting - ) { - doTransition("unselect_continent", {}); - } else if ( - anastasisState.reducerState.recovery_state === - RecoveryStates.CountrySelecting - ) { - doTransition("unselect_continent", {}); } else { doTransition("back", {}); } }, + dismissError() { + setAnastasisState({ ...anastasisState, currentError: undefined }); + }, + reset() { + setAnastasisState({ + ...anastasisState, + currentError: undefined, + reducerState: undefined, + }); + }, + runTransaction(f) { + async function run() { + const txHandle = new ReducerTxImpl(anastasisState.reducerState!); + try { + await f(txHandle); + } catch (e) { + console.log("exception during reducer transaction", e); + } + const s = txHandle.transactionState; + console.log("transaction finished, new state", s); + if (s.code !== undefined) { + setAnastasisState({ + ...anastasisState, + currentError: txHandle.transactionState, + }); + } else { + setAnastasisState({ + ...anastasisState, + reducerState: txHandle.transactionState, + currentError: undefined, + }); + } + } + run(); + }, }; } + +class ReducerTxImpl implements ReducerTransactionHandle { + constructor(public transactionState: ReducerState) {} + async transition(action: string, args: any): Promise<ReducerState> { + console.log("making transition in transaction", action); + this.transactionState = await reduceState( + this.transactionState, + action, + args, + ); + // Abort transaction as soon as we transition into an error state. + if (this.transactionState.code !== undefined) { + throw Error("transition resulted in error"); + } + return this.transactionState; + } +} diff --git a/packages/anastasis-webui/src/routes/home/index.tsx b/packages/anastasis-webui/src/routes/home/index.tsx index ee3399503..f61897682 100644 --- a/packages/anastasis-webui/src/routes/home/index.tsx +++ b/packages/anastasis-webui/src/routes/home/index.tsx @@ -1,80 +1,290 @@ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { FunctionalComponent, h } from "preact"; import { useState } from "preact/hooks"; import { AnastasisReducerApi, + AuthMethod, + BackupStates, + ReducerStateBackup, + ReducerStateRecovery, useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import style from "./style.css"; +interface ContinentSelectionProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +function isBackup(reducer: AnastasisReducerApi) { + return !!reducer.currentReducerState?.backup_state; +} + +function ContinentSelection(props: ContinentSelectionProps) { + const { reducer, reducerState } = props; + return ( + <div class={style.home}> + <h1>{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent</h1> + <ErrorBanner reducer={reducer} /> + <div> + {reducerState.continents.map((x: any) => { + const sel = (x: string) => + reducer.transition("select_continent", { continent: x }); + return ( + <button onClick={() => sel(x.name)} key={x.name}> + {x.name} + </button> + ); + })} + </div> + <div> + <button onClick={() => reducer.back()}>Back</button> + </div> + </div> + ); +} + +interface CountrySelectionProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +function CountrySelection(props: CountrySelectionProps) { + const { reducer, reducerState } = props; + return ( + <div class={style.home}> + <h1>Backup: Select Country</h1> + <ErrorBanner reducer={reducer} /> + <div> + {reducerState.countries.map((x: any) => { + const sel = (x: any) => + reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); + return ( + <button onClick={() => sel(x)} key={x.name}> + {x.name} ({x.currency}) + </button> + ); + })} + </div> + <div> + <button onClick={() => reducer.back()}>Back</button> + </div> + </div> + ); +} + const Home: FunctionalComponent = () => { const reducer = useAnastasisReducer(); - if (!reducer.currentReducerState) { + const reducerState = reducer.currentReducerState; + if (!reducerState) { return ( <div class={style.home}> <h1>Home</h1> <p> - <button onClick={() => reducer.startBackup()}>Backup</button> - <button>Recover</button> + <button autoFocus onClick={() => reducer.startBackup()}> + Backup + </button> + <button onClick={() => reducer.startRecover()}>Recover</button> </p> </div> ); } console.log("state", reducer.currentReducerState); - if (reducer.currentReducerState.backup_state === "CONTINENT_SELECTING") { + + if (reducerState.backup_state === BackupStates.ContinentSelecting) { + return <ContinentSelection reducer={reducer} reducerState={reducerState} />; + } + if (reducerState.backup_state === BackupStates.CountrySelecting) { + return <CountrySelection reducer={reducer} reducerState={reducerState} />; + } + if (reducerState.backup_state === BackupStates.UserAttributesCollecting) { + return <AttributeEntry reducer={reducer} backupState={reducerState} />; + } + if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { + return ( + <AuthenticationEditor backupState={reducerState} reducer={reducer} /> + ); + } + + if (reducerState.backup_state === BackupStates.PoliciesReviewing) { + const backupState: ReducerStateBackup = reducerState; + const authMethods = backupState.authentication_methods!; return ( <div class={style.home}> - <h1>Backup: Select Continent</h1> + <h1>Backup: Review Recovery Policies</h1> <ErrorBanner reducer={reducer} /> <div> - {reducer.currentReducerState.continents.map((x: any) => { - const sel = (x: string) => - reducer.transition("select_continent", { continent: x }); + {backupState.policies?.map((p, i) => { + const policyName = p.methods + .map((x) => authMethods[x.authentication_method].type) + .join(" + "); return ( - <button onClick={() => sel(x.name)} key={x.name}> - {x.name} - </button> + <div class={style.policy}> + <h3> + Policy #{i + 1}: {policyName} + </h3> + Required Authentications: + <ul> + {p.methods.map((x) => { + const m = authMethods[x.authentication_method]; + return ( + <li> + {m.type} ({m.instructions}) at provider {x.provider} + </li> + ); + })} + </ul> + <div> + <button + onClick={() => + reducer.transition("delete_policy", { policy_index: i }) + } + > + Delete Policy + </button> + </div> + </div> ); })} </div> <div> <button onClick={() => reducer.back()}>Back</button> + <button onClick={() => reducer.transition("next", {})}>Next</button> </div> </div> ); } - if (reducer.currentReducerState.backup_state === "COUNTRY_SELECTING") { + + if (reducerState.backup_state === BackupStates.SecretEditing) { + const [secretName, setSecretName] = useState(""); + const [secretValue, setSecretValue] = useState(""); + const secretNext = () => { + reducer.runTransaction(async (tx) => { + await tx.transition("enter_secret_name", { + name: secretName, + }); + await tx.transition("enter_secret", { + secret: { + value: "EDJP6WK5EG50", + mime: "text/plain", + }, + expiration: { + t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, + }, + }); + await tx.transition("next", {}); + }); + }; return ( <div class={style.home}> - <h1>Backup: Select Continent</h1> + <h1>Backup: Provide secret</h1> <ErrorBanner reducer={reducer} /> <div> - {reducer.currentReducerState.countries.map((x: any) => { - const sel = (x: any) => - reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); + <label> + Secret name: <input type="text" /> + </label> + </div> + <div> + <label> + Secret value: <input type="text" /> + </label> + </div> + or: + <div> + <label> + File Upload: <input type="file" /> + </label> + </div> + <div> + <button onClick={() => reducer.back()}>Back</button> + <button onClick={() => secretNext()}>Next</button> + </div> + </div> + ); + } + + if (reducerState.backup_state === BackupStates.BackupFinished) { + const backupState: ReducerStateBackup = reducerState; + return ( + <div class={style.home}> + <h1>Backup finished</h1> + <p> + Your backup of secret "{backupState.secret_name ?? "??"}" was + successful. + </p> + <p>The backup is stored by the following providers:</p> + <ul> + {Object.keys(backupState.success_details).map((x, i) => { + const sd = backupState.success_details[x]; return ( - <button onClick={() => sel(x)} key={x.name}> - {x.name} ({x.currency}) - </button> + <li> + {x} (Policy version {sd.policy_version}) + </li> ); })} - </div> + </ul> + <button onClick={() => reducer.reset()}> + Start a new backup/recovery + </button> + </div> + ); + } + + if (reducerState.backup_state === BackupStates.TruthsPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.payments ?? []; + return ( + <div class={style.home}> + <h1>Backup: Authentication Storage Payments</h1> + <p> + Some of the providers require a payment to store the encrypted + authentication information. + </p> + <ul> + {payments.map((x) => { + return <li>{x}</li>; + })} + </ul> <div> <button onClick={() => reducer.back()}>Back</button> + <button onClick={() => reducer.transition("pay", {})}> + Check payment(s) + </button> </div> </div> ); } - if ( - reducer.currentReducerState.backup_state === "USER_ATTRIBUTES_COLLECTING" - ) { - return <AttributeEntry reducer={reducer} />; - } - if (reducer.currentReducerState.backup_state === "AUTHENTICATIONS_EDITING") { - return <AuthenticationEditor reducer={reducer} />; + if (reducerState.backup_state === BackupStates.PoliciesPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.policy_payment_requests ?? []; + return ( + <div class={style.home}> + <h1>Backup: Recovery Document Payments</h1> + <p> + Some of the providers require a payment to store the encrypted + recovery document. + </p> + <ul> + {payments.map((x) => { + return ( + <li> + {x.provider}: {x.payto} + </li> + ); + })} + </ul> + <div> + <button onClick={() => reducer.back()}>Back</button> + <button onClick={() => reducer.transition("pay", {})}> + Check payment(s) + </button> + </div> + </div> + ); } console.log("unknown state", reducer.currentReducerState); @@ -82,31 +292,232 @@ const Home: FunctionalComponent = () => { <div class={style.home}> <h1>Home</h1> <p>Bug: Unknown state.</p> + <button onClick={() => reducer.reset()}>Reset</button> </div> ); }; +interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + cancel: () => void; +} + +function AuthMethodSmsSetup(props: AuthMethodSetupProps) { + const [mobileNumber, setMobileNumber] = useState(""); + return ( + <div class={style.home}> + <h1>Add {props.method} authentication</h1> + <div> + <p> + For SMS authentication, you need to provide a mobile number. When + recovering your secret, you will be asked to enter the code you + receive via SMS. + </p> + <label> + Mobile number{" "} + <input + value={mobileNumber} + autoFocus + onChange={(e) => setMobileNumber((e.target as any).value)} + type="text" + /> + </label> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button + onClick={() => + props.addAuthMethod({ + authentication_method: { + type: "sms", + instructions: `SMS to ${mobileNumber}`, + challenge: "E1QPPS8A", + }, + }) + } + > + Add + </button> + </div> + </div> + </div> + ); +} + +function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + return ( + <div class={style.home}> + <h1>Add {props.method} authentication</h1> + <div> + <p> + For security question authentication, you need to provide a question + and its answer. When recovering your secret, you will be shown the + question and you will need to type the answer exactly as you typed it + here. + </p> + <div> + <label> + Security question + <input + value={questionText} + autoFocus + onChange={(e) => setQuestionText((e.target as any).value)} + type="text" + /> + </label> + </div> + <div> + <label> + Answer + <input + value={answerText} + autoFocus + onChange={(e) => setAnswerText((e.target as any).value)} + type="text" + /> + </label> + </div> + <div> + <button onClick={() => props.cancel()}>Cancel</button> + <button + onClick={() => + props.addAuthMethod({ + authentication_method: { + type: "question", + instructions: questionText, + challenge: encodeCrock(stringToBytes(answerText)), + }, + }) + } + > + Add + </button> + </div> + </div> + </div> + ); +} + +function AuthMethodNotImplemented(props: AuthMethodSetupProps) { + return ( + <div class={style.home}> + <h1>Add {props.method} authentication</h1> + <div> + <p> + This auth method is not implemented yet, please choose another one. + </p> + <button onClick={() => props.cancel()}>Cancel</button> + </div> + </div> + ); +} + export interface AuthenticationEditorProps { reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; } function AuthenticationEditor(props: AuthenticationEditorProps) { - const { reducer } = props; - const providers = reducer.currentReducerState.authentication_providers; - const authAvailable = new Set<string>(); + const [selectedMethod, setSelectedMethod] = useState<string | undefined>( + undefined, + ); + const { reducer, backupState } = props; + const providers = backupState.authentication_providers; + const authAvailableSet = new Set<string>(); for (const provKey of Object.keys(providers)) { const p = providers[provKey]; for (const meth of p.methods) { - authAvailable.add(meth.type); + authAvailableSet.add(meth.type); + } + } + if (selectedMethod) { + const cancel = () => setSelectedMethod(undefined); + const addMethod = (args: any) => { + reducer.transition("add_authentication", args); + setSelectedMethod(undefined); + }; + switch (selectedMethod) { + case "sms": + return ( + <AuthMethodSmsSetup + cancel={cancel} + addAuthMethod={addMethod} + method="sms" + /> + ); + case "question": + return ( + <AuthMethodQuestionSetup + cancel={cancel} + addAuthMethod={addMethod} + method="sms" + /> + ); + default: + return ( + <AuthMethodNotImplemented + cancel={cancel} + addAuthMethod={addMethod} + method={selectedMethod} + /> + ); } } + function MethodButton(props: { method: string; label: String }) { + return ( + <button + disabled={!authAvailableSet.has(props.method)} + onClick={() => { + setSelectedMethod(props.method); + reducer.dismissError(); + }} + > + {props.label} + </button> + ); + } + const configuredAuthMethods: AuthMethod[] = + backupState.authentication_methods ?? []; + const haveMethodsConfigured = configuredAuthMethods.length; return ( <div class={style.home}> <h1>Backup: Configure Authentication Methods</h1> - <p>Auths available: {JSON.stringify(Array.from(authAvailable))}</p> - <button>Next</button> + <ErrorBanner reducer={reducer} /> + <h2>Add authentication method</h2> + <div> + <MethodButton method="sms" label="SMS" /> + <MethodButton method="email" label="Email" /> + <MethodButton method="question" label="Question" /> + <MethodButton method="post" label="Physical Mail" /> + <MethodButton method="totp" label="TOTP" /> + <MethodButton method="iban" label="IBAN" /> + </div> + <h2>Configured authentication methods</h2> + {haveMethodsConfigured ? ( + configuredAuthMethods.map((x, i) => { + return ( + <p> + {x.type} ({x.instructions}){" "} + <button + onClick={() => + reducer.transition("delete_authentication", { + authentication_method: i, + }) + } + > + Delete + </button> + </p> + ); + }) + ) : ( + <p>No authentication methods configured yet.</p> + )} <div> <button onClick={() => reducer.back()}>Back</button> + <button onClick={() => reducer.transition("next", {})}>Next</button> </div> </div> ); @@ -114,19 +525,21 @@ function AuthenticationEditor(props: AuthenticationEditorProps) { export interface AttributeEntryProps { reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; } function AttributeEntry(props: AttributeEntryProps) { - const reducer = props.reducer; + const { reducer, backupState } = props; const [attrs, setAttrs] = useState<Record<string, string>>({}); return ( <div class={style.home}> <h1>Backup: Enter Basic User Attributes</h1> <ErrorBanner reducer={reducer} /> <div> - {reducer.currentReducerState.required_attributes.map((x: any) => { + {backupState.required_attributes.map((x: any, i: number) => { return ( <AttributeEntryField + isFirst={i == 0} setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} spec={x} value={attrs[x.name]} @@ -134,23 +547,24 @@ function AttributeEntry(props: AttributeEntryProps) { ); })} </div> - <button - onClick={() => - reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - }) - } - > - Next - </button> <div> <button onClick={() => reducer.back()}>Back</button> + <button + onClick={() => + reducer.transition("enter_user_attributes", { + identity_attributes: attrs, + }) + } + > + Next + </button> </div> </div> ); } export interface AttributeEntryFieldProps { + isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: any; @@ -161,6 +575,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps) { <div> <label>{props.spec.label}</label> <input + autoFocus={props.isFirst} type="text" value={props.value} onChange={(e) => props.setValue((e as any).target.value)} @@ -179,7 +594,14 @@ interface ErrorBannerProps { function ErrorBanner(props: ErrorBannerProps) { const currentError = props.reducer.currentError; if (currentError) { - return <div>Error: {JSON.stringify(currentError)}</div>; + return ( + <div id={style.error}> + <p>Error: {JSON.stringify(currentError)}</p> + <button onClick={() => props.reducer.dismissError()}> + Dismiss Error + </button> + </div> + ); } return null; } diff --git a/packages/anastasis-webui/src/routes/home/style.css b/packages/anastasis-webui/src/routes/home/style.css index f052d2546..c9f34e6c8 100644 --- a/packages/anastasis-webui/src/routes/home/style.css +++ b/packages/anastasis-webui/src/routes/home/style.css @@ -1,5 +1,24 @@ .home { - padding: 56px 20px; - min-height: 100%; - width: 100%; + padding: 56px 20px; + min-height: 100%; + width: 100%; +} + +.home div { + margin-top: 0.5em; + margin-bottom: 0.5em; +} + +.policy { + padding: 0.5em; + border: 1px solid black; + border-radius: 0.5em; + border-radius: 0.5em; +} + +.home > #error { + padding: 0.5em; + border: 1px solid black; + background-color: rgb(228, 189, 197); + border-radius: 0.5em; } |