reducer WIP, user error boundaries in UI
This commit is contained in:
parent
cf25f5698e
commit
0ee669f523
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@ -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": {
|
||||
|
@ -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);
|
||||
});
|
||||
|
@ -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[];
|
||||
|
@ -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",
|
||||
|
66
packages/anastasis-core/src/recovery-document-types.ts
Normal file
66
packages/anastasis-core/src/recovery-document-types.ts
Normal 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;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user