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": { "search.exclude": {
"dist": true, "dist": true,
"prebuilt": true, "prebuilt": true,
"src/i18n/*.po": true "src/i18n/*.po": true,
"vendor": true
}, },
"search.collapseResults": "auto", "search.collapseResults": "auto",
"files.associations": { "files.associations": {

View File

@ -1,6 +1,7 @@
import test from "ava"; import test from "ava";
import { import {
accountKeypairDerive, accountKeypairDerive,
decryptTruth,
encryptKeyshare, encryptKeyshare,
encryptTruth, encryptTruth,
policyKeyDerive, policyKeyDerive,
@ -94,4 +95,8 @@ test("truth encryption", async (t) => {
tv.input_truth, tv.input_truth,
); );
t.is(enc, tv.output_encrypted_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, secretbox,
crypto_sign_keyPair_fromSeed, crypto_sign_keyPair_fromSeed,
stringToBytes, stringToBytes,
secretbox_open,
} from "@gnu-taler/taler-util"; } from "@gnu-taler/taler-util";
import { gzipSync } from "fflate"; import { gzipSync } from "fflate";
import { argon2id } from "hash-wasm"; import { argon2id } from "hash-wasm";
@ -87,7 +88,7 @@ export function accountKeypairDerive(userId: UserIdentifier): AccountKeyPair {
/** /**
* Encrypt the recovery document. * Encrypt the recovery document.
* *
* The caller should first compress the recovery doc. * The caller should first compress the recovery doc.
*/ */
export async function encryptRecoveryDocument( export async function encryptRecoveryDocument(
@ -95,12 +96,19 @@ export async function encryptRecoveryDocument(
recoveryDocData: OpaqueData, recoveryDocData: OpaqueData,
): Promise<OpaqueData> { ): Promise<OpaqueData> {
const nonce = encodeCrock(getRandomBytes(nonceSize)); const nonce = encodeCrock(getRandomBytes(nonceSize));
return anastasisEncrypt( return anastasisEncrypt(nonce, asOpaque(userId), recoveryDocData, "erd");
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 { export function typedArrayConcat(chunks: Uint8Array[]): Uint8Array {
@ -158,6 +166,22 @@ async function anastasisEncrypt(
return encodeCrock(typedArrayConcat([nonceBuf, cipherText])); 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; export const asOpaque = (x: string): OpaqueData => x;
const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string; const asEncryptedKeyShare = (x: OpaqueData): EncryptedKeyShare => x as string;
const asEncryptedTruth = (x: OpaqueData): EncryptedTruth => 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 { export interface CoreSecretEncResult {
encCoreSecret: EncryptedCoreSecret; encCoreSecret: EncryptedCoreSecret;
encMasterKeys: EncryptedMasterKey[]; encMasterKeys: EncryptedMasterKey[];

View File

@ -2,6 +2,8 @@ import {
AmountString, AmountString,
buildSigPS, buildSigPS,
bytesToString, bytesToString,
Codec,
codecForAny,
decodeCrock, decodeCrock,
eddsaSign, eddsaSign,
encodeCrock, encodeCrock,
@ -24,6 +26,7 @@ import {
ActionArgEnterSecret, ActionArgEnterSecret,
ActionArgEnterSecretName, ActionArgEnterSecretName,
ActionArgEnterUserAttributes, ActionArgEnterUserAttributes,
ActionArgSelectChallenge,
AuthenticationProviderStatus, AuthenticationProviderStatus,
AuthenticationProviderStatusOk, AuthenticationProviderStatusOk,
AuthMethod, AuthMethod,
@ -33,6 +36,8 @@ import {
MethodSpec, MethodSpec,
Policy, Policy,
PolicyProvider, PolicyProvider,
RecoveryInformation,
RecoveryInternalData,
RecoveryStates, RecoveryStates,
ReducerState, ReducerState,
ReducerStateBackup, ReducerStateBackup,
@ -60,78 +65,15 @@ import {
UserIdentifier, UserIdentifier,
userIdentifierDerive, userIdentifierDerive,
typedArrayConcat, typedArrayConcat,
decryptRecoveryDocument,
} from "./crypto.js"; } 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({}); const { fetch, Request, Response, Headers } = fetchPonyfill({});
export * from "./reducer-types.js"; 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[] { function getContinents(): ContinentInfo[] {
const continentSet = new Set<string>(); const continentSet = new Set<string>();
const continents: ContinentInfo[] = []; 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( async function getProviderInfo(
providerBaseUrl: string, providerBaseUrl: string,
): Promise<AuthenticationProviderStatus> { ): Promise<AuthenticationProviderStatus> {
@ -436,6 +413,13 @@ async function compressRecoveryDoc(rd: any): Promise<Uint8Array> {
return typedArrayConcat([new Uint8Array(sizeHeaderBuf), zippedDoc]); 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( async function uploadSecret(
state: ReducerStateBackup, state: ReducerStateBackup,
): Promise<ReducerStateBackup | ReducerStateError> { ): 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( export async function reduceAction(
state: ReducerState, state: ReducerState,
action: string, 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 { return {
code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID,
hint: "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; 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 { export interface ReducerStateRecovery {
backup_state?: undefined; backup_state?: undefined;
recovery_state: RecoveryStates; recovery_state: RecoveryStates;
@ -102,23 +118,20 @@ export interface ReducerStateRecovery {
continents?: any; continents?: any;
countries?: any; countries?: any;
selected_continent?: string;
selected_country?: string;
currencies?: string[];
required_attributes?: any; required_attributes?: any;
recovery_information?: { /**
challenges: ChallengeInfo[]; * Recovery information, used by the UI.
policies: { */
/** recovery_information?: RecoveryInformation;
* UUID of the associated challenge.
*/
uuid: string;
}[][];
};
recovery_document?: { // FIXME: This should really be renamed to recovery_internal_data
secret_name: string; recovery_document?: RecoveryInternalData;
provider_url: string;
version: number;
};
selected_challenge_uuid?: string; selected_challenge_uuid?: string;
@ -129,11 +142,7 @@ export interface ReducerStateRecovery {
value: string; value: string;
}; };
authentication_providers?: { authentication_providers?: { [url: string]: AuthenticationProviderStatus };
[url: string]: {
business_name: string;
};
};
recovery_error?: any; recovery_error?: any;
} }
@ -244,3 +253,7 @@ export interface ActionArgEnterSecret {
}; };
expiration: Duration; expiration: Duration;
} }
export interface ActionArgSelectChallenge {
uuid: string;
}

View File

@ -164,10 +164,12 @@ export function useAnastasisReducer(): AnastasisReducerApi {
} else { } else {
s = await reduceAction(anastasisState.reducerState!, action, args); 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) { if (s.code) {
console.log("response is an error");
setAnastasisState({ ...anastasisState, currentError: s }); setAnastasisState({ ...anastasisState, currentError: s });
} else { } else {
console.log("response is a new state");
setAnastasisState({ setAnastasisState({
...anastasisState, ...anastasisState,
currentError: undefined, currentError: undefined,

View File

@ -57,7 +57,7 @@ export function SecretSelectionScreen(props: RecoveryReducerProps): VNode {
<AnastasisClientFrame title="Recovery: Select secret"> <AnastasisClientFrame title="Recovery: Select secret">
<p>Provider: {recoveryDocument.provider_url}</p> <p>Provider: {recoveryDocument.provider_url}</p>
<p>Secret version: {recoveryDocument.version}</p> <p>Secret version: {recoveryDocument.version}</p>
<p>Secret name: {recoveryDocument.version}</p> <p>Secret name: {recoveryDocument.secret_name}</p>
<button onClick={() => setSelectingVersion(true)}> <button onClick={() => setSelectingVersion(true)}>
Select different secret Select different secret
</button> </button>

View File

@ -1,17 +1,28 @@
import { import {
ComponentChildren, createContext, Component,
Fragment, FunctionalComponent, h, VNode ComponentChildren,
createContext,
Fragment,
FunctionalComponent,
h,
VNode,
} from "preact"; } from "preact";
import { useContext, useLayoutEffect, useRef } from "preact/hooks"; import {
useContext,
useErrorBoundary,
useLayoutEffect,
useRef,
} from "preact/hooks";
import { Menu } from "../../components/menu"; import { Menu } from "../../components/menu";
import { import {
BackupStates, RecoveryStates, BackupStates,
RecoveryStates,
ReducerStateBackup, ReducerStateBackup,
ReducerStateRecovery, ReducerStateRecovery,
} from "anastasis-core"; } from "anastasis-core";
import { import {
AnastasisReducerApi, AnastasisReducerApi,
useAnastasisReducer useAnastasisReducer,
} from "../../hooks/use-anastasis-reducer"; } from "../../hooks/use-anastasis-reducer";
import { AttributeEntryScreen } from "./AttributeEntryScreen"; import { AttributeEntryScreen } from "./AttributeEntryScreen";
import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen";
@ -27,7 +38,7 @@ import { SecretSelectionScreen } from "./SecretSelectionScreen";
import { SolveScreen } from "./SolveScreen"; import { SolveScreen } from "./SolveScreen";
import { StartScreen } from "./StartScreen"; import { StartScreen } from "./StartScreen";
import { TruthsPayingScreen } from "./TruthsPayingScreen"; import { TruthsPayingScreen } from "./TruthsPayingScreen";
import "./../home/style" import "./../home/style";
const WithReducer = createContext<AnastasisReducerApi | undefined>(undefined); const WithReducer = createContext<AnastasisReducerApi | undefined>(undefined);
@ -40,7 +51,10 @@ export interface CommonReducerProps {
reducerState: ReducerStateBackup | ReducerStateRecovery; reducerState: ReducerStateBackup | ReducerStateRecovery;
} }
export function withProcessLabel(reducer: AnastasisReducerApi, text: string): string { export function withProcessLabel(
reducer: AnastasisReducerApi,
text: string,
): string {
if (isBackup(reducer)) { if (isBackup(reducer)) {
return `Backup: ${text}`; return `Backup: ${text}`;
} }
@ -71,6 +85,33 @@ interface AnastasisClientFrameProps {
hideNext?: boolean; 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 { export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode {
const reducer = useContext(WithReducer); const reducer = useContext(WithReducer);
if (!reducer) { if (!reducer) {
@ -83,29 +124,30 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode {
reducer.transition("next", {}); 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); console.log("Got key press", e.key);
// FIXME: By default, "next" action should be executed here // FIXME: By default, "next" action should be executed here
}; };
return (<Fragment> return (
<Menu title="Anastasis" /> <Fragment>
<section class="section"> <Menu title="Anastasis" />
<div class="home" onKeyPress={(e) => handleKeyPress(e)}> <div>
<button onClick={() => reducer.reset()}>Reset session</button> <div class="home" onKeyPress={(e) => handleKeyPress(e)}>
<h1>{props.title}</h1> <button onClick={() => reducer.reset()}>Reset session</button>
<ErrorBanner reducer={reducer} /> <h1>{props.title}</h1>
{props.children} <ErrorBanner reducer={reducer} />
{!props.hideNav ? ( {props.children}
<div> {!props.hideNav ? (
<button onClick={() => reducer.back()}>Back</button> <div>
{!props.hideNext ? ( <button onClick={() => reducer.back()}>Back</button>
<button onClick={next}>Next</button> {!props.hideNext ? <button onClick={next}>Next</button> : null}
) : null} </div>
</div> ) : null}
) : null} </div>
</div> </div>
</section> </Fragment>
</Fragment>
); );
} }
@ -113,7 +155,9 @@ const AnastasisClient: FunctionalComponent = () => {
const reducer = useAnastasisReducer(); const reducer = useAnastasisReducer();
return ( return (
<WithReducer.Provider value={reducer}> <WithReducer.Provider value={reducer}>
<AnastasisClientImpl /> <ErrorBoundary reducer={reducer}>
<AnastasisClientImpl />
</ErrorBoundary>
</WithReducer.Provider> </WithReducer.Provider>
); );
}; };
@ -130,27 +174,38 @@ const AnastasisClientImpl: FunctionalComponent = () => {
reducerState.backup_state === BackupStates.ContinentSelecting || reducerState.backup_state === BackupStates.ContinentSelecting ||
reducerState.recovery_state === RecoveryStates.ContinentSelecting reducerState.recovery_state === RecoveryStates.ContinentSelecting
) { ) {
return <ContinentSelectionScreen reducer={reducer} reducerState={reducerState} />; return (
<ContinentSelectionScreen reducer={reducer} reducerState={reducerState} />
);
} }
if ( if (
reducerState.backup_state === BackupStates.CountrySelecting || reducerState.backup_state === BackupStates.CountrySelecting ||
reducerState.recovery_state === RecoveryStates.CountrySelecting reducerState.recovery_state === RecoveryStates.CountrySelecting
) { ) {
return <CountrySelectionScreen reducer={reducer} reducerState={reducerState} />; return (
<CountrySelectionScreen reducer={reducer} reducerState={reducerState} />
);
} }
if ( if (
reducerState.backup_state === BackupStates.UserAttributesCollecting || reducerState.backup_state === BackupStates.UserAttributesCollecting ||
reducerState.recovery_state === RecoveryStates.UserAttributesCollecting reducerState.recovery_state === RecoveryStates.UserAttributesCollecting
) { ) {
return <AttributeEntryScreen reducer={reducer} reducerState={reducerState} />; return (
<AttributeEntryScreen reducer={reducer} reducerState={reducerState} />
);
} }
if (reducerState.backup_state === BackupStates.AuthenticationsEditing) { if (reducerState.backup_state === BackupStates.AuthenticationsEditing) {
return ( return (
<AuthenticationEditorScreen backupState={reducerState} reducer={reducer} /> <AuthenticationEditorScreen
backupState={reducerState}
reducer={reducer}
/>
); );
} }
if (reducerState.backup_state === BackupStates.PoliciesReviewing) { if (reducerState.backup_state === BackupStates.PoliciesReviewing) {
return <ReviewPoliciesScreen reducer={reducer} backupState={reducerState} />; return (
<ReviewPoliciesScreen reducer={reducer} backupState={reducerState} />
);
} }
if (reducerState.backup_state === BackupStates.SecretEditing) { if (reducerState.backup_state === BackupStates.SecretEditing) {
return <SecretEditorScreen reducer={reducer} backupState={reducerState} />; return <SecretEditorScreen reducer={reducer} backupState={reducerState} />;
@ -162,29 +217,34 @@ const AnastasisClientImpl: FunctionalComponent = () => {
} }
if (reducerState.backup_state === BackupStates.TruthsPaying) { if (reducerState.backup_state === BackupStates.TruthsPaying) {
return <TruthsPayingScreen reducer={reducer} backupState={reducerState} /> return <TruthsPayingScreen reducer={reducer} backupState={reducerState} />;
} }
if (reducerState.backup_state === BackupStates.PoliciesPaying) { if (reducerState.backup_state === BackupStates.PoliciesPaying) {
const backupState: ReducerStateBackup = reducerState; const backupState: ReducerStateBackup = reducerState;
return <PoliciesPayingScreen reducer={reducer} backupState={backupState} /> return <PoliciesPayingScreen reducer={reducer} backupState={backupState} />;
} }
if (reducerState.recovery_state === RecoveryStates.SecretSelecting) { if (reducerState.recovery_state === RecoveryStates.SecretSelecting) {
return <SecretSelectionScreen reducer={reducer} recoveryState={reducerState} />; return (
<SecretSelectionScreen reducer={reducer} recoveryState={reducerState} />
);
} }
if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) { if (reducerState.recovery_state === RecoveryStates.ChallengeSelecting) {
return <ChallengeOverviewScreen reducer={reducer} recoveryState={reducerState} />; return (
<ChallengeOverviewScreen reducer={reducer} recoveryState={reducerState} />
);
} }
if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) { if (reducerState.recovery_state === RecoveryStates.ChallengeSolving) {
return <SolveScreen reducer={reducer} recoveryState={reducerState} /> return <SolveScreen reducer={reducer} recoveryState={reducerState} />;
} }
if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) { if (reducerState.recovery_state === RecoveryStates.RecoveryFinished) {
return <RecoveryFinishedScreen reducer={reducer} recoveryState={reducerState} /> return (
<RecoveryFinishedScreen reducer={reducer} recoveryState={reducerState} />
);
} }
console.log("unknown state", reducer.currentReducerState); console.log("unknown state", reducer.currentReducerState);
@ -196,7 +256,6 @@ const AnastasisClientImpl: FunctionalComponent = () => {
); );
}; };
interface LabeledInputProps { interface LabeledInputProps {
label: string; label: string;
grabFocus?: boolean; grabFocus?: boolean;
@ -223,7 +282,6 @@ export function LabeledInput(props: LabeledInputProps): VNode {
); );
} }
interface ErrorBannerProps { interface ErrorBannerProps {
reducer: AnastasisReducerApi; reducer: AnastasisReducerApi;
} }
@ -235,7 +293,7 @@ function ErrorBanner(props: ErrorBannerProps): VNode | null {
const currentError = props.reducer.currentError; const currentError = props.reducer.currentError;
if (currentError) { if (currentError) {
return ( return (
<div id="error"> <div id="error">
<p>Error: {JSON.stringify(currentError)}</p> <p>Error: {JSON.stringify(currentError)}</p>
<button onClick={() => props.reducer.dismissError()}> <button onClick={() => props.reducer.dismissError()}>
Dismiss Error Dismiss Error

View File

@ -226,4 +226,10 @@ div[data-tooltip]::before {
.notfound { .notfound {
padding: 0 5%; padding: 0 5%;
margin: 100px 0; margin: 100px 0;
}
h1 {
font-size: 1.5em;
margin-top: 0.8em;
margin-bottom: 0.8em;
} }