reducer WIP, user error boundaries in UI

This commit is contained in:
Florian Dold 2021-10-21 13:11:17 +02:00
parent cf25f5698e
commit 0ee669f523
No known key found for this signature in database
GPG Key ID: D2E4F00F29D02A4B
10 changed files with 521 additions and 137 deletions

View File

@ -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": {

View File

@ -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);
});

View File

@ -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[];

View File

@ -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",

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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,

View File

@ -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>

View File

@ -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

View File

@ -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;
}