summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFlorian Dold <florian@dold.me>2021-10-21 13:11:17 +0200
committerFlorian Dold <florian@dold.me>2021-10-21 13:11:33 +0200
commit0ee669f52341a8331394a1e9892264c0ef0bb7d7 (patch)
tree5a4d1a02ad6acd0dd04edde2dc032160c299700f
parentcf25f5698e9f3a3b36930e545f7cce9829fb08f6 (diff)
downloadwallet-core-0ee669f52341a8331394a1e9892264c0ef0bb7d7.tar.gz
wallet-core-0ee669f52341a8331394a1e9892264c0ef0bb7d7.tar.bz2
wallet-core-0ee669f52341a8331394a1e9892264c0ef0bb7d7.zip
reducer WIP, user error boundaries in UI
-rw-r--r--.vscode/settings.json3
-rw-r--r--packages/anastasis-core/src/crypto.test.ts5
-rw-r--r--packages/anastasis-core/src/crypto.ts50
-rw-r--r--packages/anastasis-core/src/index.ts329
-rw-r--r--packages/anastasis-core/src/recovery-document-types.ts66
-rw-r--r--packages/anastasis-core/src/reducer-types.ts51
-rw-r--r--packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts4
-rw-r--r--packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx2
-rw-r--r--packages/anastasis-webui/src/pages/home/index.tsx142
-rw-r--r--packages/anastasis-webui/src/scss/main.scss6
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