diff options
-rw-r--r-- | .vscode/settings.json | 3 | ||||
-rw-r--r-- | packages/anastasis-core/src/crypto.test.ts | 5 | ||||
-rw-r--r-- | packages/anastasis-core/src/crypto.ts | 50 | ||||
-rw-r--r-- | packages/anastasis-core/src/index.ts | 329 | ||||
-rw-r--r-- | packages/anastasis-core/src/recovery-document-types.ts | 66 | ||||
-rw-r--r-- | packages/anastasis-core/src/reducer-types.ts | 51 | ||||
-rw-r--r-- | packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts | 4 | ||||
-rw-r--r-- | packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx | 2 | ||||
-rw-r--r-- | packages/anastasis-webui/src/pages/home/index.tsx | 142 | ||||
-rw-r--r-- | packages/anastasis-webui/src/scss/main.scss | 6 |
10 files changed, 521 insertions, 137 deletions
diff --git a/.vscode/settings.json b/.vscode/settings.json index 52b266708..d8e616936 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -39,7 +39,8 @@ "search.exclude": { "dist": true, "prebuilt": true, - "src/i18n/*.po": true + "src/i18n/*.po": true, + "vendor": true }, "search.collapseResults": "auto", "files.associations": { diff --git a/packages/anastasis-core/src/crypto.test.ts b/packages/anastasis-core/src/crypto.test.ts index 1c255014a..c0f5e41c1 100644 --- a/packages/anastasis-core/src/crypto.test.ts +++ b/packages/anastasis-core/src/crypto.test.ts @@ -1,6 +1,7 @@ import test from "ava"; import { accountKeypairDerive, + decryptTruth, encryptKeyshare, encryptTruth, policyKeyDerive, @@ -94,4 +95,8 @@ test("truth encryption", async (t) => { tv.input_truth, ); t.is(enc, tv.output_encrypted_truth); + + const dec = await decryptTruth(tv.input_truth_enc_key, enc); + + t.is(dec, tv.input_truth); }); diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index 63de795b0..8df893f4b 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -9,6 +9,7 @@ import { secretbox, crypto_sign_keyPair_fromSeed, stringToBytes, + secretbox_open, } from "@gnu-taler/taler-util"; import { gzipSync } from "fflate"; import { argon2id } from "hash-wasm"; @@ -87,7 +88,7 @@ export function accountKeypairDerive(userId: UserIdentifier): AccountKeyPair { /** * Encrypt the recovery document. - * + * * The caller should first compress the recovery doc. */ export async function encryptRecoveryDocument( @@ -95,12 +96,19 @@ export async function encryptRecoveryDocument( recoveryDocData: OpaqueData, ): Promise<OpaqueData> { const nonce = encodeCrock(getRandomBytes(nonceSize)); - return anastasisEncrypt( - nonce, - asOpaque(userId), - recoveryDocData, - "erd", - ); + return anastasisEncrypt(nonce, asOpaque(userId), recoveryDocData, "erd"); +} + +/** + * Encrypt the recovery document. + * + * The caller should first compress the recovery doc. + */ +export async function decryptRecoveryDocument( + userId: UserIdentifier, + recoveryDocData: OpaqueData, +): Promise<OpaqueData> { + return anastasisDecrypt(asOpaque(userId), recoveryDocData, "erd"); } export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array { @@ -158,6 +166,22 @@ async function anastasisEncrypt( return encodeCrock(typedArrayConcat([nonceBuf, cipherText])); } +async function anastasisDecrypt( + keySeed: OpaqueData, + ciphertext: OpaqueData, + salt: string, +): Promise<OpaqueData> { + const ctBuf = decodeCrock(ciphertext); + const nonceBuf = ctBuf.slice(0, nonceSize); + const enc = ctBuf.slice(nonceSize); + const key = await deriveKey(keySeed, encodeCrock(nonceBuf), salt); + const cipherText = secretbox_open(enc, nonceBuf, key); + if (!cipherText) { + throw Error("could not decrypt"); + } + return encodeCrock(cipherText); +} + export const asOpaque = (x: string): OpaqueData => x; const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string; const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => x as string; @@ -185,6 +209,18 @@ export async function encryptTruth( ); } +export async function decryptTruth( + truthEncKey: TruthKey, + truthEnc: EncryptedTruth, +): Promise<OpaqueData> { + const salt = "ect"; + return await anastasisDecrypt( + asOpaque(truthEncKey), + asOpaque(truthEnc), + salt, + ); +} + export interface CoreSecretEncResult { encCoreSecret: EncryptedCoreSecret; encMasterKeys: EncryptedMasterKey[]; diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index c99bd5b44..b8fedf006 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -2,6 +2,8 @@ import { AmountString, buildSigPS, bytesToString, + Codec, + codecForAny, decodeCrock, eddsaSign, encodeCrock, @@ -24,6 +26,7 @@ import { ActionArgEnterSecret, ActionArgEnterSecretName, ActionArgEnterUserAttributes, + ActionArgSelectChallenge, AuthenticationProviderStatus, AuthenticationProviderStatusOk, AuthMethod, @@ -33,6 +36,8 @@ import { MethodSpec, Policy, PolicyProvider, + RecoveryInformation, + RecoveryInternalData, RecoveryStates, ReducerState, ReducerStateBackup, @@ -60,78 +65,15 @@ import { UserIdentifier, userIdentifierDerive, typedArrayConcat, + decryptRecoveryDocument, } from "./crypto.js"; -import { zlibSync } from "fflate"; +import { unzlibSync, zlibSync } from "fflate"; +import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js"; const { fetch, Request, Response, Headers } = fetchPonyfill({}); export * from "./reducer-types.js"; -interface RecoveryDocument { - // Human-readable name of the secret - secret_name?: string; - - // Encrypted core secret. - encrypted_core_secret: string; // bytearray of undefined length - - // List of escrow providers and selected authentication method. - escrow_methods: EscrowMethod[]; - - // List of possible decryption policies. - policies: DecryptionPolicy[]; -} - -interface DecryptionPolicy { - // Salt included to encrypt master key share when - // using this decryption policy. - salt: string; - - /** - * Master key, AES-encrypted with key derived from - * salt and keyshares revealed by the following list of - * escrow methods identified by UUID. - */ - master_key: string; - - /** - * List of escrow methods identified by their UUID. - */ - uuids: string[]; -} - -interface EscrowMethod { - /** - * URL of the escrow provider (including possibly this Anastasis server). - */ - url: string; - - /** - * Type of the escrow method (e.g. security question, SMS etc.). - */ - escrow_type: string; - - // UUID of the escrow method. - // 16 bytes base32-crock encoded. - uuid: TruthUuid; - - // Key used to encrypt the Truth this EscrowMethod is related to. - // Client has to provide this key to the server when using /truth/. - truth_key: TruthKey; - - /** - * Salt to hash the security question answer if applicable. - */ - truth_salt: TruthSalt; - - // Salt from the provider to derive the user ID - // at this provider. - provider_salt: string; - - // The instructions to give to the user (i.e. the security question - // if this is challenge-response). - instructions: string; -} - function getContinents(): ContinentInfo[] { const continentSet = new Set<string>(); const continents: ContinentInfo[] = []; @@ -203,6 +145,41 @@ async function backupSelectCountry( }; } +async function recoverySelectCountry( + state: ReducerStateRecovery, + countryCode: string, + currencies: string[], +): Promise<ReducerStateError | ReducerStateRecovery> { + const country = anastasisData.countriesList.countries.find( + (x) => x.code === countryCode, + ); + if (!country) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "invalid country selected", + }; + } + + const providers: { [x: string]: {} } = {}; + for (const prov of anastasisData.providersList.anastasis_provider) { + if (currencies.includes(prov.currency)) { + providers[prov.url] = {}; + } + } + + const ra = (anastasisData.countryDetails as any)[countryCode] + .required_attributes; + + return { + ...state, + recovery_state: RecoveryStates.UserAttributesCollecting, + selected_country: countryCode, + currencies, + required_attributes: ra, + authentication_providers: providers, + }; +} + async function getProviderInfo( providerBaseUrl: string, ): Promise<AuthenticationProviderStatus> { @@ -436,6 +413,13 @@ async function compressRecoveryDoc(rd: any): Promise<Uint8Array> { return typedArrayConcat([new Uint8Array(sizeHeaderBuf), zippedDoc]); } +async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise<any> { + const header = zippedRd.slice(0, 4); + const data = zippedRd.slice(4); + const res = unzlibSync(data); + return JSON.parse(bytesToString(res)); +} + async function uploadSecret( state: ReducerStateBackup, ): Promise<ReducerStateBackup | ReducerStateError> { @@ -632,6 +616,97 @@ async function uploadSecret( }; } +/** + * Download policy based on current user attributes and selected + * version in the state. + */ +async function downloadPolicy( + state: ReducerStateRecovery, +): Promise<ReducerStateRecovery | ReducerStateError> { + const providerUrls = Object.keys(state.authentication_providers ?? {}); + let foundRecoveryInfo: RecoveryInternalData | undefined = undefined; + let recoveryDoc: RecoveryDocument | undefined = undefined; + const newProviderStatus: { [url: string]: AuthenticationProviderStatus } = {}; + const userAttributes = state.identity_attributes!; + for (const url of providerUrls) { + const pi = await getProviderInfo(url); + if ("error_code" in pi || !("http_status" in pi)) { + // Could not even get /config of the provider + continue; + } + newProviderStatus[url] = pi; + const userId = await userIdentifierDerive(userAttributes, pi.salt); + const acctKeypair = accountKeypairDerive(userId); + const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href); + if (resp.status !== 200) { + continue; + } + const body = await resp.arrayBuffer(); + const bodyDecrypted = await decryptRecoveryDocument( + userId, + encodeCrock(body), + ); + const rd: RecoveryDocument = await uncompressRecoveryDoc( + decodeCrock(bodyDecrypted), + ); + console.log("rd", rd); + let policyVersion = 0; + try { + policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); + } catch (e) {} + foundRecoveryInfo = { + provider_url: url, + secret_name: rd.secret_name ?? "<unknown>", + version: policyVersion, + }; + recoveryDoc = rd; + break; + } + if (!foundRecoveryInfo || !recoveryDoc) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_POLICY_LOOKUP_FAILED, + hint: "No backups found at any provider for your identity information.", + }; + } + const recoveryInfo: RecoveryInformation = { + challenges: recoveryDoc.escrow_methods.map((x) => { + console.log("providers", state.authentication_providers); + const prov = newProviderStatus[x.url] as AuthenticationProviderStatusOk; + return { + cost: prov.methods.find((m) => m.type === x.escrow_type)?.usage_fee!, + instructions: x.instructions, + type: x.escrow_type, + uuid: x.uuid, + }; + }), + policies: recoveryDoc.policies.map((x) => { + return x.uuids.map((m) => { + return { + uuid: m, + }; + }); + }), + }; + return { + ...state, + recovery_state: RecoveryStates.SecretSelecting, + recovery_document: foundRecoveryInfo, + recovery_information: recoveryInfo, + }; +} + +async function recoveryEnterUserAttributes( + state: ReducerStateRecovery, + attributes: Record<string, string>, +): Promise<ReducerStateRecovery | ReducerStateError> { + // FIXME: validate attributes + const st: ReducerStateRecovery = { + ...state, + identity_attributes: attributes, + }; + return downloadPolicy(st); +} + export async function reduceAction( state: ReducerState, action: string, @@ -827,6 +902,128 @@ export async function reduceAction( }; } } + + if (state.recovery_state === RecoveryStates.ContinentSelecting) { + if (action === "select_continent") { + const continent: string = args.continent; + if (typeof continent !== "string") { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "continent required", + }; + } + return { + ...state, + recovery_state: RecoveryStates.CountrySelecting, + countries: getCountries(continent), + selected_continent: continent, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.CountrySelecting) { + if (action === "back") { + return { + ...state, + recovery_state: RecoveryStates.ContinentSelecting, + countries: undefined, + }; + } else if (action === "select_country") { + const countryCode = args.country_code; + if (typeof countryCode !== "string") { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "country_code required", + }; + } + const currencies = args.currencies; + return recoverySelectCountry(state, countryCode, currencies); + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.UserAttributesCollecting) { + if (action === "back") { + return { + ...state, + recovery_state: RecoveryStates.CountrySelecting, + }; + } else if (action === "enter_user_attributes") { + const ta = args as ActionArgEnterUserAttributes; + return recoveryEnterUserAttributes(state, ta.identity_attributes); + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.SecretSelecting) { + if (action === "back") { + return { + ...state, + recovery_state: RecoveryStates.UserAttributesCollecting, + }; + } else if (action === "next") { + return { + ...state, + recovery_state: RecoveryStates.ChallengeSelecting, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.ChallengeSelecting) { + if (action === "select_challenge") { + const ta: ActionArgSelectChallenge = args; + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + selected_challenge_uuid: ta.uuid, + }; + } else if (action === "back") { + return { + ...state, + recovery_state: RecoveryStates.SecretSelecting, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + + if (state.recovery_state === RecoveryStates.ChallengeSolving) { + if (action === "back") { + const ta: ActionArgSelectChallenge = args; + return { + ...state, + selected_challenge_uuid: undefined, + recovery_state: RecoveryStates.ChallengeSelecting, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}'`, + }; + } + } + return { code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, hint: "Reducer action invalid", diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts new file mode 100644 index 000000000..a1d9a55fc --- /dev/null +++ b/packages/anastasis-core/src/recovery-document-types.ts @@ -0,0 +1,66 @@ +import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js"; + +export interface RecoveryDocument { + // Human-readable name of the secret + secret_name?: string; + + // Encrypted core secret. + encrypted_core_secret: string; // bytearray of undefined length + + // List of escrow providers and selected authentication method. + escrow_methods: EscrowMethod[]; + + // List of possible decryption policies. + policies: DecryptionPolicy[]; +} + +export interface DecryptionPolicy { + // Salt included to encrypt master key share when + // using this decryption policy. + salt: string; + + /** + * Master key, AES-encrypted with key derived from + * salt and keyshares revealed by the following list of + * escrow methods identified by UUID. + */ + master_key: string; + + /** + * List of escrow methods identified by their UUID. + */ + uuids: string[]; +} + +export interface EscrowMethod { + /** + * URL of the escrow provider (including possibly this Anastasis server). + */ + url: string; + + /** + * Type of the escrow method (e.g. security question, SMS etc.). + */ + escrow_type: string; + + // UUID of the escrow method. + // 16 bytes base32-crock encoded. + uuid: TruthUuid; + + // Key used to encrypt the Truth this EscrowMethod is related to. + // Client has to provide this key to the server when using /truth/. + truth_key: TruthKey; + + /** + * Salt to hash the security question answer if applicable. + */ + truth_salt: TruthSalt; + + // Salt from the provider to derive the user ID + // at this provider. + provider_salt: string; + + // The instructions to give to the user (i.e. the security question + // if this is challenge-response). + instructions: string; +} diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 44761ea0a..4c73dfa66 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -93,6 +93,22 @@ export interface UserAttributeSpec { widget: string; } +export interface RecoveryInternalData { + secret_name: string; + provider_url: string; + version: number; +} + +export interface RecoveryInformation { + challenges: ChallengeInfo[]; + policies: { + /** + * UUID of the associated challenge. + */ + uuid: string; + }[][]; +} + export interface ReducerStateRecovery { backup_state?: undefined; recovery_state: RecoveryStates; @@ -102,23 +118,20 @@ export interface ReducerStateRecovery { continents?: any; countries?: any; + + selected_continent?: string; + selected_country?: string; + currencies?: string[]; + required_attributes?: any; - recovery_information?: { - challenges: ChallengeInfo[]; - policies: { - /** - * UUID of the associated challenge. - */ - uuid: string; - }[][]; - }; + /** + * Recovery information, used by the UI. + */ + recovery_information?: RecoveryInformation; - recovery_document?: { - secret_name: string; - provider_url: string; - version: number; - }; + // FIXME: This should really be renamed to recovery_internal_data + recovery_document?: RecoveryInternalData; selected_challenge_uuid?: string; @@ -129,11 +142,7 @@ export interface ReducerStateRecovery { value: string; }; - authentication_providers?: { - [url: string]: { - business_name: string; - }; - }; + authentication_providers?: { [url: string]: AuthenticationProviderStatus }; recovery_error?: any; } @@ -244,3 +253,7 @@ export interface ActionArgEnterSecret { }; expiration: Duration; } + +export interface ActionArgSelectChallenge { + uuid: string; +} diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index 4a242a2e5..72594749d 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -164,10 +164,12 @@ export function useAnastasisReducer(): AnastasisReducerApi { } else { s = await reduceAction(anastasisState.reducerState!, action, args); } - console.log("got new state from reducer", s); + console.log("got response from reducer", s); if (s.code) { + console.log("response is an error"); setAnastasisState({ ...anastasisState, currentError: s }); } else { + console.log("response is a new state"); setAnastasisState({ ...anastasisState, currentError: undefined, diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx index bbdcf8c2e..7cb7fdf20 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -57,7 +57,7 @@ export function SecretSelectionScreen(props: RecoveryReducerProps): VNode { <AnastasisClientFrame title="Recovery: Select secret"> <p>Provider: {recoveryDocument.provider_url}</p> <p>Secret version: {recoveryDocument.version}</p> - <p>Secret name: {recoveryDocument.version}</p> + <p>Secret name: {recoveryDocument.secret_name}</p> <button onClick={() => setSelectingVersion(true)}> Select different secret </button> diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx index 6e9ea07fc..5001d1ee4 100644 --- a/packages/anastasis-webui/src/pages/home/index.tsx +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -1,17 +1,28 @@ import { - ComponentChildren, createContext, - Fragment, FunctionalComponent, h, VNode + Component, + ComponentChildren, + createContext, + Fragment, + FunctionalComponent, + h, + VNode, } from "preact"; -import { useContext, useLayoutEffect, useRef } from "preact/hooks"; +import { + useContext, + useErrorBoundary, + useLayoutEffect, + useRef, +} from "preact/hooks"; import { Menu } from "../../components/menu"; import { - BackupStates, RecoveryStates, + BackupStates, + RecoveryStates, ReducerStateBackup, ReducerStateRecovery, } from "anastasis-core"; import { AnastasisReducerApi, - useAnastasisReducer + useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import { AttributeEntryScreen } from "./AttributeEntryScreen"; import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; @@ -27,7 +38,7 @@ import { SecretSelectionScreen } from "./SecretSelectionScreen"; import { SolveScreen } from "./SolveScreen"; import { StartScreen } from "./StartScreen"; import { TruthsPayingScreen } from "./TruthsPayingScreen"; -import "./../home/style" +import "./../home/style"; const WithReducer = createContext<AnastasisReducerApi | undefined>(undefined); @@ -40,7 +51,10 @@ export interface CommonReducerProps { reducerState: ReducerStateBackup | ReducerStateRecovery; } -export function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { +export function withProcessLabel( + reducer: AnastasisReducerApi, + text: string, +): string { if (isBackup(reducer)) { return `Backup: ${text}`; } @@ -71,6 +85,33 @@ interface AnastasisClientFrameProps { hideNext?: boolean; } +function ErrorBoundary(props: { + reducer: AnastasisReducerApi; + children: ComponentChildren; +}) { + const [error, resetError] = useErrorBoundary((error) => + console.log("got error", error), + ); + if (error) { + return ( + <div> + <button + onClick={() => { + props.reducer.reset(); + resetError(); + }} + > + Reset + </button> + <p> + Error: <pre>{error.stack}</pre> + </p> + </div> + ); + } + return <div>{props.children}</div>; +} + export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { const reducer = useContext(WithReducer); if (!reducer) { @@ -83,29 +124,30 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { reducer.transition("next", {}); } }; - const handleKeyPress = (e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>): void => { + const handleKeyPress = ( + e: h.JSX.TargetedKeyboardEvent<HTMLDivElement>, + ): void => { console.log("Got key press", e.key); // FIXME: By default, "next" action should be executed here }; - return (<Fragment> - <Menu title="Anastasis" /> - <section class="section"> - <div class="home" onKeyPress={(e) => handleKeyPress(e)}> - <button onClick={() => reducer.reset()}>Reset session</button> - <h1>{props.title}</h1> - <ErrorBanner reducer={reducer} /> - {props.children} - {!props.hideNav ? ( - <div> - <button onClick={() => reducer.back()}>Back</button> - {!props.hideNext ? ( - <button onClick={next}>Next</button> - ) : null} - </div> - ) : null} + return ( + <Fragment> + <Menu title="Anastasis" /> + <div> + <div class="home" onKeyPress={(e) => handleKeyPress(e)}> + <button onClick={() => reducer.reset()}>Reset session</button> + <h1>{props.title}</h1> + <ErrorBanner reducer={reducer} /> + {props.children} + {!props.hideNav ? ( + <div> + <button onClick={() => reducer.back()}>Back</button> + {!props.hideNext ? <button onClick={next}>Next</button> : null} + </div> + ) : null} + </div> </div> - </section> - </Fragment> + </Fragment> ); } @@ -113,7 +155,9 @@ const AnastasisClient: FunctionalComponent = () => { const reducer = useAnastasisReducer(); return ( <WithReducer.Provider value={reducer}> - <AnastasisClientImpl /> + <ErrorBoundary reducer={reducer}> + <AnastasisClientImpl /> + </ErrorBoundary> </WithReducer.Provider> ); }; @@ -130,27 +174,38 @@ const AnastasisClientImpl: FunctionalComponent = () => { reducerState.backup_state === BackupStates.ContinentSelecting || reducerState.recovery_state === RecoveryStates.ContinentSelecting ) { - return <ContinentSelectionScreen reducer={reducer} reducerState={reducerState} />; + return ( + <ContinentSelectionScreen reducer={reducer} reducerState={reducerState} /> + ); } if ( reducerState.backup_state === BackupStates.CountrySelecting || reducerState.recovery_state === RecoveryStates.CountrySelecting ) { - return <CountrySelectionScreen reducer={reducer} reducerState={reducerState} />; + return ( + <CountrySelectionScreen reducer={reducer} reducerState={reducerState} /> + ); } if ( reducerState.backup_state === BackupStates.UserAttributesCollecting || reducerState.recovery_state === RecoveryStates.UserAttributesCollecting ) { - return <AttributeEntryScreen reducer={reducer} reducerState={reducerState} />; + return ( + <AttributeEntryScreen reducer={reducer} reducerState={reducerState} /> + ); } if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { return ( - <AuthenticationEditorScreen backupState={reducerState} reducer={reducer} /> + <AuthenticationEditorScreen + backupState={reducerState} + reducer={reducer} + /> ); } if (reducerState.backup_state === BackupStates.PoliciesReviewing) { - return <ReviewPoliciesScreen reducer={reducer} backupState={reducerState} />; + return ( + <ReviewPoliciesScreen reducer={reducer} backupState={reducerState} /> + ); } if (reducerState.backup_state === BackupStates.SecretEditing) { return <SecretEditorScreen reducer={reducer} backupState={reducerState} />; @@ -162,29 +217,34 @@ const AnastasisClientImpl: FunctionalComponent = () => { } if (reducerState.backup_state === BackupStates.TruthsPaying) { - return <TruthsPayingScreen reducer={reducer} backupState={reducerState} /> - + return <TruthsPayingScreen reducer={reducer} backupState={reducerState} />; } if (reducerState.backup_state === BackupStates.PoliciesPaying) { const backupState: ReducerStateBackup = reducerState; - return <PoliciesPayingScreen reducer={reducer} backupState={backupState} /> + return <PoliciesPayingScreen reducer={reducer} backupState={backupState} />; } if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { - return <SecretSelectionScreen reducer={reducer} recoveryState={reducerState} />; + return ( + <SecretSelectionScreen reducer={reducer} recoveryState={reducerState} /> + ); } if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { - return <ChallengeOverviewScreen reducer={reducer} recoveryState={reducerState} />; + return ( + <ChallengeOverviewScreen reducer={reducer} recoveryState={reducerState} /> + ); } if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { - return <SolveScreen reducer={reducer} recoveryState={reducerState} /> + return <SolveScreen reducer={reducer} recoveryState={reducerState} />; } if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { - return <RecoveryFinishedScreen reducer={reducer} recoveryState={reducerState} /> + return ( + <RecoveryFinishedScreen reducer={reducer} recoveryState={reducerState} /> + ); } console.log("unknown state", reducer.currentReducerState); @@ -196,7 +256,6 @@ const AnastasisClientImpl: FunctionalComponent = () => { ); }; - interface LabeledInputProps { label: string; grabFocus?: boolean; @@ -223,7 +282,6 @@ export function LabeledInput(props: LabeledInputProps): VNode { ); } - interface ErrorBannerProps { reducer: AnastasisReducerApi; } @@ -235,7 +293,7 @@ function ErrorBanner(props: ErrorBannerProps): VNode | null { const currentError = props.reducer.currentError; if (currentError) { return ( - <div id="error"> + <div id="error"> <p>Error: {JSON.stringify(currentError)}</p> <button onClick={() => props.reducer.dismissError()}> Dismiss Error diff --git a/packages/anastasis-webui/src/scss/main.scss b/packages/anastasis-webui/src/scss/main.scss index b22557618..2e60bf6f9 100644 --- a/packages/anastasis-webui/src/scss/main.scss +++ b/packages/anastasis-webui/src/scss/main.scss @@ -226,4 +226,10 @@ div[data-tooltip]::before { .notfound { padding: 0 5%; margin: 100px 0; +} + +h1 { + font-size: 1.5em; + margin-top: 0.8em; + margin-bottom: 0.8em; }
\ No newline at end of file |