From 0f1ef7eca1f1ab3c5a1787b19a6caec13fb30dec Mon Sep 17 00:00:00 2001 From: Florian Dold Date: Wed, 13 Oct 2021 10:48:25 +0200 Subject: anastasis-webui: finish backup flow --- .../src/hooks/use-anastasis-reducer.ts | 185 +++++++- packages/anastasis-webui/src/routes/home/index.tsx | 518 +++++++++++++++++++-- packages/anastasis-webui/src/routes/home/style.css | 25 +- 3 files changed, 657 insertions(+), 71 deletions(-) (limited to 'packages/anastasis-webui/src') 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; +} + 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; +} + +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({ - reducerState: undefined, - currentError: undefined, - }); + const [anastasisState, setAnastasisStateInternal] = useState( + () => ({ + 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 { + 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 ( +
+

{isBackup(reducer) ? "Backup" : "Recovery"}: Select Continent

+ +
+ {reducerState.continents.map((x: any) => { + const sel = (x: string) => + reducer.transition("select_continent", { continent: x }); + return ( + + ); + })} +
+
+ +
+
+ ); +} + +interface CountrySelectionProps { + reducer: AnastasisReducerApi; + reducerState: ReducerStateBackup | ReducerStateRecovery; +} + +function CountrySelection(props: CountrySelectionProps) { + const { reducer, reducerState } = props; + return ( +
+

Backup: Select Country

+ +
+ {reducerState.countries.map((x: any) => { + const sel = (x: any) => + reducer.transition("select_country", { + country_code: x.code, + currencies: [x.currency], + }); + return ( + + ); + })} +
+
+ +
+
+ ); +} + const Home: FunctionalComponent = () => { const reducer = useAnastasisReducer(); - if (!reducer.currentReducerState) { + const reducerState = reducer.currentReducerState; + if (!reducerState) { return (

Home

- - + +

); } console.log("state", reducer.currentReducerState); - if (reducer.currentReducerState.backup_state === "CONTINENT_SELECTING") { + + if (reducerState.backup_state === BackupStates.ContinentSelecting) { + return ; + } + if (reducerState.backup_state === BackupStates.CountrySelecting) { + return ; + } + if (reducerState.backup_state === BackupStates.UserAttributesCollecting) { + return ; + } + if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { + return ( + + ); + } + + if (reducerState.backup_state === BackupStates.PoliciesReviewing) { + const backupState: ReducerStateBackup = reducerState; + const authMethods = backupState.authentication_methods!; return (
-

Backup: Select Continent

+

Backup: Review Recovery Policies

- {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 ( - +
+

+ Policy #{i + 1}: {policyName} +

+ Required Authentications: +
    + {p.methods.map((x) => { + const m = authMethods[x.authentication_method]; + return ( +
  • + {m.type} ({m.instructions}) at provider {x.provider} +
  • + ); + })} +
+
+ +
+
); })}
+
); } - 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 (
-

Backup: Select Continent

+

Backup: Provide secret

- {reducer.currentReducerState.countries.map((x: any) => { - const sel = (x: any) => - reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); + +
+
+ +
+ or: +
+ +
+
+ + +
+
+ ); + } + + if (reducerState.backup_state === BackupStates.BackupFinished) { + const backupState: ReducerStateBackup = reducerState; + return ( +
+

Backup finished

+

+ Your backup of secret "{backupState.secret_name ?? "??"}" was + successful. +

+

The backup is stored by the following providers:

+
    + {Object.keys(backupState.success_details).map((x, i) => { + const sd = backupState.success_details[x]; return ( - +
  • + {x} (Policy version {sd.policy_version}) +
  • ); })} -
+ + + + ); + } + + if (reducerState.backup_state === BackupStates.TruthsPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.payments ?? []; + return ( +
+

Backup: Authentication Storage Payments

+

+ Some of the providers require a payment to store the encrypted + authentication information. +

+
    + {payments.map((x) => { + return
  • {x}
  • ; + })} +
+
); } - if ( - reducer.currentReducerState.backup_state === "USER_ATTRIBUTES_COLLECTING" - ) { - return ; - } - if (reducer.currentReducerState.backup_state === "AUTHENTICATIONS_EDITING") { - return ; + if (reducerState.backup_state === BackupStates.PoliciesPaying) { + const backupState: ReducerStateBackup = reducerState; + const payments = backupState.policy_payment_requests ?? []; + return ( +
+

Backup: Recovery Document Payments

+

+ Some of the providers require a payment to store the encrypted + recovery document. +

+
    + {payments.map((x) => { + return ( +
  • + {x.provider}: {x.payto} +
  • + ); + })} +
+
+ + +
+
+ ); } console.log("unknown state", reducer.currentReducerState); @@ -82,31 +292,232 @@ const Home: FunctionalComponent = () => {

Home

Bug: Unknown state.

+
); }; +interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + cancel: () => void; +} + +function AuthMethodSmsSetup(props: AuthMethodSetupProps) { + const [mobileNumber, setMobileNumber] = useState(""); + return ( +
+

Add {props.method} authentication

+
+

+ 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. +

+ +
+ + +
+
+
+ ); +} + +function AuthMethodQuestionSetup(props: AuthMethodSetupProps) { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + return ( +
+

Add {props.method} authentication

+
+

+ 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. +

+
+ +
+
+ +
+
+ + +
+
+
+ ); +} + +function AuthMethodNotImplemented(props: AuthMethodSetupProps) { + return ( +
+

Add {props.method} authentication

+
+

+ This auth method is not implemented yet, please choose another one. +

+ +
+
+ ); +} + export interface AuthenticationEditorProps { reducer: AnastasisReducerApi; + backupState: ReducerStateBackup; } function AuthenticationEditor(props: AuthenticationEditorProps) { - const { reducer } = props; - const providers = reducer.currentReducerState.authentication_providers; - const authAvailable = new Set(); + const [selectedMethod, setSelectedMethod] = useState( + undefined, + ); + const { reducer, backupState } = props; + const providers = backupState.authentication_providers; + const authAvailableSet = new Set(); 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 ( + + ); + case "question": + return ( + + ); + default: + return ( + + ); } } + function MethodButton(props: { method: string; label: String }) { + return ( + + ); + } + const configuredAuthMethods: AuthMethod[] = + backupState.authentication_methods ?? []; + const haveMethodsConfigured = configuredAuthMethods.length; return (

Backup: Configure Authentication Methods

-

Auths available: {JSON.stringify(Array.from(authAvailable))}

- + +

Add authentication method

+
+ + + + + + +
+

Configured authentication methods

+ {haveMethodsConfigured ? ( + configuredAuthMethods.map((x, i) => { + return ( +

+ {x.type} ({x.instructions}){" "} + +

+ ); + }) + ) : ( +

No authentication methods configured yet.

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

Backup: Enter Basic User Attributes

- {reducer.currentReducerState.required_attributes.map((x: any) => { + {backupState.required_attributes.map((x: any, i: number) => { return ( setAttrs({ ...attrs, [x.name]: v })} spec={x} value={attrs[x.name]} @@ -134,23 +547,24 @@ function AttributeEntry(props: AttributeEntryProps) { ); })}
-
+
); } export interface AttributeEntryFieldProps { + isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: any; @@ -161,6 +575,7 @@ function AttributeEntryField(props: AttributeEntryFieldProps) {
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
Error: {JSON.stringify(currentError)}
; + return ( +
+

Error: {JSON.stringify(currentError)}

+ +
+ ); } 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; } -- cgit v1.2.3