diff options
Diffstat (limited to 'packages')
122 files changed, 8446 insertions, 2482 deletions
diff --git a/packages/anastasis-core/bin/anastasis-ts-reducer.js b/packages/anastasis-core/bin/anastasis-ts-reducer.js new file mode 100755 index 000000000..9e1120516 --- /dev/null +++ b/packages/anastasis-core/bin/anastasis-ts-reducer.js @@ -0,0 +1,14 @@ +#!/usr/bin/env node + +async function r() { + try { + (await import("source-map-support")).install(); + } catch (e) { + console.warn("can't load souremaps"); + // Do nothing. + } + + (await import("../dist/anastasis-cli.js")).reducerCliMain(); +} + +r(); diff --git a/packages/anastasis-core/package.json b/packages/anastasis-core/package.json index 8dbef2d45..7e4fba9e3 100644 --- a/packages/anastasis-core/package.json +++ b/packages/anastasis-core/package.json @@ -6,8 +6,8 @@ "module": "./lib/index.js", "types": "./lib/index.d.ts", "scripts": { - "prepare": "tsc", - "compile": "tsc", + "prepare": "tsc && rollup -c", + "compile": "tsc && rollup -c", "pretty": "prettier --write src", "test": "tsc && ava", "coverage": "tsc && nyc ava", @@ -17,15 +17,23 @@ "license": "AGPL-3-or-later", "type": "module", "devDependencies": { + "@rollup/plugin-commonjs": "^21.0.1", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.0.6", "ava": "^3.15.0", - "typescript": "^4.4.3" + "rimraf": "^3.0.2", + "rollup": "^2.59.0", + "rollup-plugin-sourcemaps": "^0.6.3", + "source-map-support": "^0.5.19", + "typescript": "^4.4.4" }, "dependencies": { "@gnu-taler/taler-util": "workspace:^0.8.3", "fetch-ponyfill": "^7.1.0", "fflate": "^0.6.0", "hash-wasm": "^4.9.0", - "node-fetch": "^3.0.0" + "node-fetch": "^3.0.0", + "tslib": "^2.1.0" }, "ava": { "files": [ diff --git a/packages/anastasis-core/rollup.config.js b/packages/anastasis-core/rollup.config.js new file mode 100644 index 000000000..a9af1d3b5 --- /dev/null +++ b/packages/anastasis-core/rollup.config.js @@ -0,0 +1,56 @@ +// rollup.config.js +import commonjs from "@rollup/plugin-commonjs"; +import nodeResolve from "@rollup/plugin-node-resolve"; +import json from "@rollup/plugin-json"; +import builtins from "builtin-modules"; +import sourcemaps from "rollup-plugin-sourcemaps"; + +const cli = { + input: "lib/index.node.js", + output: { + file: "dist/anastasis-cli.js", + format: "es", + sourcemap: true, + }, + external: builtins, + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + + sourcemaps(), + + commonjs({ + sourceMap: true, + transformMixedEsModules: true, + }), + + json(), + ], +}; + +const standalone = { + input: "lib/cli-entry.js", + output: { + file: "dist/anastasis-cli-standalone.js", + format: "es", + sourcemap: true, + }, + external: [...builtins, "source-map-support"], + plugins: [ + nodeResolve({ + preferBuiltins: true, + }), + + sourcemaps(), + + commonjs({ + sourceMap: true, + transformMixedEsModules: true, + }), + + json(), + ], +}; + +export default [standalone, cli]; diff --git a/packages/anastasis-core/src/challenge-feedback-types.ts b/packages/anastasis-core/src/challenge-feedback-types.ts new file mode 100644 index 000000000..0770d9296 --- /dev/null +++ b/packages/anastasis-core/src/challenge-feedback-types.ts @@ -0,0 +1,168 @@ +import { AmountString, HttpStatusCode } from "@gnu-taler/taler-util"; + +export enum ChallengeFeedbackStatus { + Solved = "solved", + ServerFailure = "server-failure", + TruthUnknown = "truth-unknown", + Redirect = "redirect", + Payment = "payment", + Pending = "pending", + Message = "message", + Unsupported = "unsupported", + RateLimitExceeded = "rate-limit-exceeded", + AuthIban = "auth-iban", +} + +export type ChallengeFeedback = + | ChallengeFeedbackSolved + | ChallengeFeedbackPending + | ChallengeFeedbackPayment + | ChallengeFeedbackServerFailure + | ChallengeFeedbackRateLimitExceeded + | ChallengeFeedbackTruthUnknown + | ChallengeFeedbackRedirect + | ChallengeFeedbackMessage + | ChallengeFeedbackUnsupported + | ChallengeFeedbackAuthIban; + +/** + * Challenge has been solved and the key share has + * been retrieved. + */ +export interface ChallengeFeedbackSolved { + state: ChallengeFeedbackStatus.Solved; +} + +/** + * The challenge given by the server is unsupported + * by the current anastasis client. + */ +export interface ChallengeFeedbackUnsupported { + state: ChallengeFeedbackStatus.Unsupported; + http_status: HttpStatusCode; + /** + * Human-readable identifier of the unsupported method. + */ + unsupported_method: string; +} + +/** + * The user tried to answer too often with a wrong answer. + */ +export interface ChallengeFeedbackRateLimitExceeded { + state: ChallengeFeedbackStatus.RateLimitExceeded; +} + +/** + * Instructions for performing authentication via an + * IBAN bank transfer. + */ +export interface ChallengeFeedbackAuthIban { + state: ChallengeFeedbackStatus.AuthIban; + + /** + * Amount that should be transfered for a successful authentication. + */ + challenge_amount: AmountString; + + /** + * Account that should be credited. + */ + credit_iban: string; + + /** + * Creditor name. + */ + business_name: string; + + /** + * Unstructured remittance information that should + * be contained in the bank transfer. + */ + wire_transfer_subject: string; + + /** + * FIXME: This field is only present for compatibility with + * the C reducer test suite. + */ + method: "iban"; + + answer_code: number; + + /** + * FIXME: This field is only present for compatibility with + * the C reducer test suite. + */ + details: { + challenge_amount: AmountString; + credit_iban: string; + business_name: string; + wire_transfer_subject: string; + }; +} + +/** + * Challenge still needs to be solved. + */ +export interface ChallengeFeedbackPending { + state: ChallengeFeedbackStatus.Pending; +} + +/** + * Human-readable response from the provider + * after the user failed to solve the challenge + * correctly. + */ +export interface ChallengeFeedbackMessage { + state: ChallengeFeedbackStatus.Message; + message: string; +} + +/** + * The server experienced a temporary failure. + */ +export interface ChallengeFeedbackServerFailure { + state: ChallengeFeedbackStatus.ServerFailure; + http_status: HttpStatusCode | 0; + + /** + * Taler-style error response, if available. + */ + error_response?: any; +} + +/** + * The truth is unknown to the provider. There + * is no reason to continue trying to solve any + * challenges in the policy. + */ +export interface ChallengeFeedbackTruthUnknown { + state: ChallengeFeedbackStatus.TruthUnknown; +} + +/** + * The user should be asked to go to a URL + * to complete the authentication there. + */ +export interface ChallengeFeedbackRedirect { + state: ChallengeFeedbackStatus.Redirect; + http_status: number; + redirect_url: string; +} + +/** + * A payment is required before the user can + * even attempt to solve the challenge. + */ +export interface ChallengeFeedbackPayment { + state: ChallengeFeedbackStatus.Payment; + + taler_pay_uri: string; + + provider: string; + + /** + * FIXME: Why is this required?! + */ + payment_secret: string; +} diff --git a/packages/anastasis-core/src/cli-entry.ts b/packages/anastasis-core/src/cli-entry.ts new file mode 100644 index 000000000..151b47f2b --- /dev/null +++ b/packages/anastasis-core/src/cli-entry.ts @@ -0,0 +1,15 @@ +import { reducerCliMain } from "./cli.js"; + +async function r() { + try { + // @ts-ignore + (await import("source-map-support")).install(); + } catch (e) { + console.warn("can't load souremaps, please install source-map-support"); + // Do nothing. + } + + reducerCliMain(); +} + +r(); diff --git a/packages/anastasis-core/src/cli.ts b/packages/anastasis-core/src/cli.ts new file mode 100644 index 000000000..517f2876d --- /dev/null +++ b/packages/anastasis-core/src/cli.ts @@ -0,0 +1,64 @@ +import { clk } from "@gnu-taler/taler-util"; +import { + getBackupStartState, + getRecoveryStartState, + reduceAction, +} from "./index.js"; +import fs from "fs"; + +export const reducerCli = clk + .program("reducer", { + help: "Command line interface for Anastasis.", + }) + .flag("initBackup", ["-b", "--backup"]) + .flag("initRecovery", ["-r", "--restore"]) + .maybeOption("argumentsJson", ["-a", "--arguments"], clk.STRING) + .maybeArgument("action", clk.STRING) + .maybeArgument("stateFile", clk.STRING); + +async function read(stream: NodeJS.ReadStream): Promise<string> { + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + return Buffer.concat(chunks).toString("utf8"); +} + +reducerCli.action(async (x) => { + if (x.reducer.initBackup) { + console.log(JSON.stringify(await getBackupStartState())); + return; + } else if (x.reducer.initRecovery) { + console.log(JSON.stringify(await getRecoveryStartState())); + return; + } + + const action = x.reducer.action; + if (!action) { + console.log("action required"); + return; + } + + let lastState: any; + if (x.reducer.stateFile) { + const s = fs.readFileSync(x.reducer.stateFile, { encoding: "utf-8" }); + lastState = JSON.parse(s); + } else { + const s = await read(process.stdin); + lastState = JSON.parse(s); + } + + let args: any; + if (x.reducer.argumentsJson) { + args = JSON.parse(x.reducer.argumentsJson); + } else { + args = {}; + } + + const nextState = await reduceAction(lastState, action, args); + console.log(JSON.stringify(nextState)); +}); + +export function reducerCliMain() { + reducerCli.run(); +} diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index da8338636..206d9eca8 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -10,8 +10,10 @@ import { crypto_sign_keyPair_fromSeed, stringToBytes, secretbox_open, + hash, + Logger, + j2s, } from "@gnu-taler/taler-util"; -import { gzipSync } from "fflate"; import { argon2id } from "hash-wasm"; export type Flavor<T, FlavorT extends string> = T & { @@ -248,7 +250,6 @@ export async function coreSecretRecover(args: { args.encryptedMasterKey, "emk", ); - console.log("recovered master key", masterKey); return await anastasisDecrypt(masterKey, args.encryptedCoreSecret, "cse"); } @@ -283,6 +284,10 @@ export async function coreSecretEncrypt( }; } +export async function pinAnswerHash(pin: number): Promise<SecureAnswerHash> { + return encodeCrock(hash(stringToBytes(pin.toString()))); +} + export async function secureAnswerHash( answer: string, truthUuid: TruthUuid, diff --git a/packages/anastasis-core/src/index.node.ts b/packages/anastasis-core/src/index.node.ts new file mode 100644 index 000000000..d08906a22 --- /dev/null +++ b/packages/anastasis-core/src/index.node.ts @@ -0,0 +1,2 @@ +export * from "./index.js"; +export { reducerCliMain } from "./cli.js"; diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index b4e911ffb..362ac3317 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -1,14 +1,22 @@ import { + AmountJson, + AmountLike, + Amounts, AmountString, buildSigPS, bytesToString, Codec, codecForAny, decodeCrock, + Duration, eddsaSign, encodeCrock, getRandomBytes, hash, + HttpStatusCode, + j2s, + Logger, + parsePayUri, stringToBytes, TalerErrorCode, TalerSignaturePurpose, @@ -17,35 +25,46 @@ import { import { anastasisData } from "./anastasis-data.js"; import { EscrowConfigurationResponse, + IbanExternalAuthResponse, TruthUploadRequest, } from "./provider-types.js"; import { - ActionArgAddAuthentication, - ActionArgDeleteAuthentication, - ActionArgDeletePolicy, - ActionArgEnterSecret, - ActionArgEnterSecretName, - ActionArgEnterUserAttributes, + ActionArgsAddAuthentication, + ActionArgsDeleteAuthentication, + ActionArgsDeletePolicy, + ActionArgsEnterSecret, + ActionArgsEnterSecretName, + ActionArgsEnterUserAttributes, + ActionArgsAddPolicy, + ActionArgsSelectContinent, + ActionArgsSelectCountry, ActionArgsSelectChallenge, ActionArgsSolveChallengeRequest, + ActionArgsUpdateExpiration, AuthenticationProviderStatus, AuthenticationProviderStatusOk, AuthMethod, BackupStates, + codecForActionArgsEnterUserAttributes, + codecForActionArgsAddPolicy, + codecForActionArgsSelectChallenge, + codecForActionArgSelectContinent, + codecForActionArgSelectCountry, + codecForActionArgsUpdateExpiration, ContinentInfo, CountryInfo, - MethodSpec, - Policy, - PolicyProvider, RecoveryInformation, RecoveryInternalData, RecoveryStates, ReducerState, ReducerStateBackup, - ReducerStateBackupUserAttributesCollecting, ReducerStateError, ReducerStateRecovery, SuccessDetails, + codecForActionArgsChangeVersion, + ActionArgsChangeVersion, + TruthMetaData, + ActionArgsUpdatePolicy, } from "./reducer-types.js"; import fetchPonyfill from "fetch-ponyfill"; import { @@ -61,8 +80,6 @@ import { PolicySalt, TruthSalt, secureAnswerHash, - TruthKey, - TruthUuid, UserIdentifier, userIdentifierDerive, typedArrayConcat, @@ -70,13 +87,27 @@ import { decryptKeyShare, KeyShare, coreSecretRecover, + pinAnswerHash, } from "./crypto.js"; import { unzlibSync, zlibSync } from "fflate"; -import { EscrowMethod, RecoveryDocument } from "./recovery-document-types.js"; +import { + ChallengeType, + EscrowMethod, + RecoveryDocument, +} from "./recovery-document-types.js"; +import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js"; +import { + ChallengeFeedback, + ChallengeFeedbackStatus, +} from "./challenge-feedback-types.js"; -const { fetch, Request, Response, Headers } = fetchPonyfill({}); +const { fetch } = fetchPonyfill({}); export * from "./reducer-types.js"; +export * as validators from "./validators.js"; +export * from "./challenge-feedback-types.js"; + +const logger = new Logger("anastasis-core:index.ts"); function getContinents(): ContinentInfo[] { const continentSet = new Set<string>(); @@ -94,10 +125,40 @@ function getContinents(): ContinentInfo[] { return continents; } +interface ErrorDetails { + code: TalerErrorCode; + message?: string; + hint?: string; +} + +export class ReducerError extends Error { + constructor(public errorJson: ErrorDetails) { + super( + errorJson.message ?? + errorJson.hint ?? + `${TalerErrorCode[errorJson.code]}`, + ); + + // Set the prototype explicitly. + Object.setPrototypeOf(this, ReducerError.prototype); + } +} + +/** + * Get countries for a continent, abort with ReducerError + * exception when continent doesn't exist. + */ function getCountries(continent: string): CountryInfo[] { - return anastasisData.countriesList.countries.filter( + const countries = anastasisData.countriesList.countries.filter( (x) => x.continent === continent, ); + if (countries.length <= 0) { + throw new ReducerError({ + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "continent not found", + }); + } + return countries; } export async function getBackupStartState(): Promise<ReducerStateBackup> { @@ -114,19 +175,27 @@ export async function getRecoveryStartState(): Promise<ReducerStateRecovery> { }; } -async function backupSelectCountry( - state: ReducerStateBackup, - countryCode: string, - currencies: string[], -): Promise<ReducerStateError | ReducerStateBackupUserAttributesCollecting> { +async function selectCountry( + selectedContinent: string, + args: ActionArgsSelectCountry, +): Promise<Partial<ReducerStateBackup> & Partial<ReducerStateRecovery>> { + const countryCode = args.country_code; + const currencies = args.currencies; const country = anastasisData.countriesList.countries.find( (x) => x.code === countryCode, ); if (!country) { - return { + throw new ReducerError({ code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, hint: "invalid country selected", - }; + }); + } + + if (country.continent !== selectedContinent) { + throw new ReducerError({ + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "selected country is not in selected continent", + }); } const providers: { [x: string]: {} } = {}; @@ -140,8 +209,6 @@ async function backupSelectCountry( .required_attributes; return { - ...state, - backup_state: BackupStates.UserAttributesCollecting, selected_country: countryCode, currencies, required_attributes: ra, @@ -149,38 +216,25 @@ async function backupSelectCountry( }; } +async function backupSelectCountry( + state: ReducerStateBackup, + args: ActionArgsSelectCountry, +): Promise<ReducerStateError | ReducerStateBackup> { + return { + ...state, + ...(await selectCountry(state.selected_continent!, args)), + backup_state: BackupStates.UserAttributesCollecting, + }; +} + async function recoverySelectCountry( state: ReducerStateRecovery, - countryCode: string, - currencies: string[], + args: ActionArgsSelectCountry, ): 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, + ...(await selectCountry(state.selected_continent!, args)), }; } @@ -230,8 +284,9 @@ async function getProviderInfo( async function backupEnterUserAttributes( state: ReducerStateBackup, - attributes: Record<string, string>, + args: ActionArgsEnterUserAttributes, ): Promise<ReducerStateBackup> { + const attributes = args.identity_attributes; const providerUrls = Object.keys(state.authentication_providers ?? {}); const newProviders = state.authentication_providers ?? {}; for (const url of providerUrls) { @@ -246,139 +301,6 @@ async function backupEnterUserAttributes( return newState; } -interface PolicySelectionResult { - policies: Policy[]; - policy_providers: PolicyProvider[]; -} - -type MethodSelection = number[]; - -function enumerateSelections(n: number, m: number): MethodSelection[] { - const selections: MethodSelection[] = []; - const a = new Array(n); - const sel = (i: number) => { - if (i === n) { - selections.push([...a]); - return; - } - const start = i == 0 ? 0 : a[i - 1] + 1; - for (let j = start; j < m; j++) { - a[i] = j; - sel(i + 1); - } - }; - sel(0); - return selections; -} - -/** - * Provider information used during provider/method mapping. - */ -interface ProviderInfo { - url: string; - methodCost: Record<string, AmountString>; -} - -/** - * Assign providers to a method selection. - */ -function assignProviders( - methods: AuthMethod[], - providers: ProviderInfo[], - methodSelection: number[], -): Policy | undefined { - const selectedProviders: string[] = []; - for (const mi of methodSelection) { - const m = methods[mi]; - let found = false; - for (const prov of providers) { - if (prov.methodCost[m.type]) { - selectedProviders.push(prov.url); - found = true; - break; - } - } - if (!found) { - /* No provider found for this method */ - return undefined; - } - } - return { - methods: methodSelection.map((x, i) => { - return { - authentication_method: x, - provider: selectedProviders[i], - }; - }), - }; -} - -function suggestPolicies( - methods: AuthMethod[], - providers: ProviderInfo[], -): PolicySelectionResult { - const numMethods = methods.length; - if (numMethods === 0) { - throw Error("no methods"); - } - let numSel: number; - if (numMethods <= 2) { - numSel = numMethods; - } else if (numMethods <= 4) { - numSel = numMethods - 1; - } else if (numMethods <= 6) { - numSel = numMethods - 2; - } else if (numMethods == 7) { - numSel = numMethods - 3; - } else { - numSel = 4; - } - const policies: Policy[] = []; - const selections = enumerateSelections(numSel, numMethods); - console.log("selections", selections); - for (const sel of selections) { - const p = assignProviders(methods, providers, sel); - if (p) { - policies.push(p); - } - } - return { - policies, - policy_providers: providers.map((x) => ({ - provider_url: x.url, - })), - }; -} - -/** - * Truth data as stored in the reducer. - */ -interface TruthMetaData { - uuid: string; - - key_share: string; - - policy_index: number; - - pol_method_index: number; - - /** - * Nonce used for encrypting the truth. - */ - nonce: string; - - /** - * Key that the truth (i.e. secret question answer, email address, mobile number, ...) - * is encrypted with when stored at the provider. - */ - truth_key: string; - - /** - * Truth-specific salt. - */ - truth_salt: string; -} - async function getTruthValue( authMethod: AuthMethod, truthUuid: string, @@ -408,7 +330,6 @@ async function getTruthValue( * Compress the recovery document and add a size header. */ async function compressRecoveryDoc(rd: any): Promise<Uint8Array> { - console.log("recovery document", rd); const docBytes = stringToBytes(JSON.stringify(rd)); const sizeHeaderBuf = new ArrayBuffer(4); const dvbuf = new DataView(sizeHeaderBuf); @@ -424,14 +345,19 @@ async function uncompressRecoveryDoc(zippedRd: Uint8Array): Promise<any> { return JSON.parse(bytesToString(res)); } -async function uploadSecret( +/** + * Prepare the recovery document and truth metadata based + * on the selected policies. + */ +async function prepareRecoveryData( state: ReducerStateBackup, -): Promise<ReducerStateBackup | ReducerStateError> { +): Promise<ReducerStateBackup> { const policies = state.policies!; const secretName = state.secret_name!; const coreSecret: OpaqueData = encodeCrock( stringToBytes(JSON.stringify(state.core_secret!)), ); + // Truth key is `${methodIndex}/${providerUrl}` const truthMetadataMap: Record<string, TruthMetaData> = {}; @@ -472,17 +398,6 @@ async function uploadSecret( const csr = await coreSecretEncrypt(policyKeys, coreSecret); - const uidMap: Record<string, UserIdentifier> = {}; - for (const prov of state.policy_providers!) { - const provider = state.authentication_providers![ - prov.provider_url - ] as AuthenticationProviderStatusOk; - uidMap[prov.provider_url] = await userIdentifierDerive( - state.identity_attributes!, - provider.salt, - ); - } - const escrowMethods: EscrowMethod[] = []; for (const truthKey of Object.keys(truthMetadataMap)) { @@ -494,24 +409,92 @@ async function uploadSecret( const provider = state.authentication_providers![ meth.provider ] as AuthenticationProviderStatusOk; + escrowMethods.push({ + escrow_type: authMethod.type as any, + instructions: authMethod.instructions, + provider_salt: provider.salt, + truth_salt: tm.truth_salt, + truth_key: tm.truth_key, + url: meth.provider, + uuid: tm.uuid, + }); + } + + const rd: RecoveryDocument = { + secret_name: secretName, + encrypted_core_secret: csr.encCoreSecret, + escrow_methods: escrowMethods, + policies: policies.map((x, i) => { + return { + master_key: csr.encMasterKeys[i], + uuids: policyUuids[i], + salt: policySalts[i], + }; + }), + }; + + return { + ...state, + recovery_data: { + recovery_document: rd, + truth_metadata: truthMetadataMap, + }, + }; +} + +async function uploadSecret( + state: ReducerStateBackup, +): Promise<ReducerStateBackup | ReducerStateError> { + if (!state.recovery_data) { + state = await prepareRecoveryData(state); + } + + const recoveryData = state.recovery_data; + if (!recoveryData) { + throw Error("invariant failed"); + } + + const truthMetadataMap = recoveryData.truth_metadata; + const rd = recoveryData.recovery_document; + + const truthPayUris: string[] = []; + const truthPaySecrets: Record<string, string> = {}; + + const userIdCache: Record<string, UserIdentifier> = {}; + const getUserIdCaching = async (providerUrl: string) => { + let userId = userIdCache[providerUrl]; + if (!userId) { + const provider = state.authentication_providers![ + providerUrl + ] as AuthenticationProviderStatusOk; + userId = userIdCache[providerUrl] = await userIdentifierDerive( + state.identity_attributes!, + provider.salt, + ); + } + return userId; + }; + for (const truthKey of Object.keys(truthMetadataMap)) { + const tm = truthMetadataMap[truthKey]; + const pol = state.policies![tm.policy_index]; + const meth = pol.methods[tm.pol_method_index]; + const authMethod = + state.authentication_methods![meth.authentication_method]; const truthValue = await getTruthValue(authMethod, tm.uuid, tm.truth_salt); const encryptedTruth = await encryptTruth( tm.nonce, tm.truth_key, truthValue, ); - const uid = uidMap[meth.provider]; + logger.info(`uploading truth to ${meth.provider}`); + const userId = await getUserIdCaching(meth.provider); const encryptedKeyShare = await encryptKeyshare( tm.key_share, - uid, + userId, authMethod.type === "question" ? bytesToString(decodeCrock(authMethod.challenge)) : undefined, ); - console.log( - "encrypted key share len", - decodeCrock(encryptedKeyShare).length, - ); const tur: TruthUploadRequest = { encrypted_truth: encryptedTruth, key_share_data: encryptedKeyShare, @@ -519,59 +502,74 @@ async function uploadSecret( type: authMethod.type, truth_mime: authMethod.mime_type, }; - const resp = await fetch(new URL(`truth/${tm.uuid}`, meth.provider).href, { + const reqUrl = new URL(`truth/${tm.uuid}`, meth.provider); + const paySecret = (state.truth_upload_payment_secrets ?? {})[meth.provider]; + if (paySecret) { + // FIXME: Get this from the params + reqUrl.searchParams.set("timeout_ms", "500"); + } + const resp = await fetch(reqUrl.href, { method: "POST", headers: { "content-type": "application/json", + ...(paySecret + ? { + "Anastasis-Payment-Identifier": paySecret, + } + : {}), }, body: JSON.stringify(tur), }); - if (resp.status !== 204) { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, - hint: "could not upload policy", - }; + if (resp.status === HttpStatusCode.NoContent) { + continue; } - - escrowMethods.push({ - escrow_type: authMethod.type, - instructions: authMethod.instructions, - provider_salt: provider.salt, - truth_salt: tm.truth_salt, - truth_key: tm.truth_key, - url: meth.provider, - uuid: tm.uuid, - }); + if (resp.status === HttpStatusCode.PaymentRequired) { + const talerPayUri = resp.headers.get("Taler"); + if (!talerPayUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + truthPayUris.push(talerPayUri); + const parsedUri = parsePayUri(talerPayUri); + if (!parsedUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + truthPaySecrets[meth.provider] = parsedUri.orderId; + continue; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, + hint: `could not upload truth (HTTP status ${resp.status})`, + }; } - // FIXME: We need to store the truth metadata in - // the state, since it's possible that we'll run into - // a provider that requests a payment. - - console.log("policy UUIDs", policyUuids); - - const rd: RecoveryDocument = { - secret_name: secretName, - encrypted_core_secret: csr.encCoreSecret, - escrow_methods: escrowMethods, - policies: policies.map((x, i) => { - return { - master_key: csr.encMasterKeys[i], - uuids: policyUuids[i], - salt: policySalts[i], - }; - }), - }; + if (truthPayUris.length > 0) { + return { + ...state, + backup_state: BackupStates.TruthsPaying, + truth_upload_payment_secrets: truthPaySecrets, + payments: truthPayUris, + }; + } const successDetails: SuccessDetails = {}; + const policyPayUris: string[] = []; + const policyPayUriMap: Record<string, string> = {}; + //const policyPaySecrets: Record<string, string> = {}; + for (const prov of state.policy_providers!) { - const uid = uidMap[prov.provider_url]; - const acctKeypair = accountKeypairDerive(uid); + const userId = await getUserIdCaching(prov.provider_url); + const acctKeypair = accountKeypairDerive(userId); const zippedDoc = await compressRecoveryDoc(rd); const encRecoveryDoc = await encryptRecoveryDocument( - uid, + userId, encodeCrock(zippedDoc), ); const bodyHash = hash(decodeCrock(encRecoveryDoc)); @@ -579,44 +577,99 @@ async function uploadSecret( .put(bodyHash) .build(); const sig = eddsaSign(sigPS, decodeCrock(acctKeypair.priv)); - const resp = await fetch( - new URL(`policy/${acctKeypair.pub}`, prov.provider_url).href, - { - method: "POST", - headers: { - "Anastasis-Policy-Signature": encodeCrock(sig), - "If-None-Match": encodeCrock(bodyHash), - }, - body: decodeCrock(encRecoveryDoc), + const talerPayUri = state.policy_payment_requests?.find( + (x) => x.provider === prov.provider_url, + )?.payto; + let paySecret: string | undefined; + if (talerPayUri) { + paySecret = parsePayUri(talerPayUri)!.orderId; + } + const reqUrl = new URL(`policy/${acctKeypair.pub}`, prov.provider_url); + if (paySecret) { + // FIXME: Get this from the params + reqUrl.searchParams.set("timeout_ms", "500"); + } + logger.info(`uploading policy to ${prov.provider_url}`); + const resp = await fetch(reqUrl.href, { + method: "POST", + headers: { + "Anastasis-Policy-Signature": encodeCrock(sig), + "If-None-Match": encodeCrock(bodyHash), + ...(paySecret + ? { + "Anastasis-Payment-Identifier": paySecret, + } + : {}), }, - ); - if (resp.status !== 204) { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, - hint: "could not upload policy", + body: decodeCrock(encRecoveryDoc), + }); + logger.info(`got response for policy upload (http status ${resp.status})`); + if (resp.status === HttpStatusCode.NoContent) { + let policyVersion = 0; + let policyExpiration: Timestamp = { t_ms: 0 }; + try { + policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); + } catch (e) {} + try { + policyExpiration = { + t_ms: + 1000 * + Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"), + }; + } catch (e) {} + successDetails[prov.provider_url] = { + policy_version: policyVersion, + policy_expiration: policyExpiration, }; + continue; } - let policyVersion = 0; - let policyExpiration: Timestamp = { t_ms: 0 }; - try { - policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); - } catch (e) {} - try { - policyExpiration = { - t_ms: - 1000 * Number(resp.headers.get("Anastasis-Policy-Expiration") ?? "0"), - }; - } catch (e) {} - successDetails[prov.provider_url] = { - policy_version: policyVersion, - policy_expiration: policyExpiration, + if (resp.status === HttpStatusCode.PaymentRequired) { + const talerPayUri = resp.headers.get("Taler"); + if (!talerPayUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + policyPayUris.push(talerPayUri); + const parsedUri = parsePayUri(talerPayUri); + if (!parsedUri) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_BACKEND_FAILURE, + hint: `payment requested, but no taler://pay URI given`, + }; + } + policyPayUriMap[prov.provider_url] = talerPayUri; + continue; + } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_NETWORK_FAILED, + hint: `could not upload policy (http status ${resp.status})`, + }; + } + + if (policyPayUris.length > 0) { + return { + ...state, + backup_state: BackupStates.PoliciesPaying, + payments: policyPayUris, + policy_payment_requests: Object.keys(policyPayUriMap).map((x) => { + return { + payto: policyPayUriMap[x], + provider: x, + }; + }), }; } + logger.info("backup finished"); + return { ...state, + core_secret: undefined, backup_state: BackupStates.BackupFinished, success_details: successDetails, + payments: undefined, }; } @@ -633,6 +686,7 @@ async function downloadPolicy( const newProviderStatus: { [url: string]: AuthenticationProviderStatusOk } = {}; const userAttributes = state.identity_attributes!; + const restrictProvider = state.selected_provider_url; // FIXME: Shouldn't we also store the status of bad providers? for (const url of providerUrls) { const pi = await getProviderInfo(url); @@ -647,9 +701,17 @@ async function downloadPolicy( if (!pi) { continue; } + if (restrictProvider && url !== state.selected_provider_url) { + // User wants specific provider. + continue; + } const userId = await userIdentifierDerive(userAttributes, pi.salt); const acctKeypair = accountKeypairDerive(userId); - const resp = await fetch(new URL(`policy/${acctKeypair.pub}`, url).href); + const reqUrl = new URL(`policy/${acctKeypair.pub}`, url); + if (state.selected_version) { + reqUrl.searchParams.set("version", `${state.selected_version}`); + } + const resp = await fetch(reqUrl.href); if (resp.status !== 200) { continue; } @@ -661,7 +723,6 @@ async function downloadPolicy( const rd: RecoveryDocument = await uncompressRecoveryDoc( decodeCrock(bodyDecrypted), ); - console.log("rd", rd); let policyVersion = 0; try { policyVersion = Number(resp.headers.get("Anastasis-Version") ?? "0"); @@ -682,7 +743,6 @@ async function downloadPolicy( } const recoveryInfo: RecoveryInformation = { challenges: recoveryDoc.escrow_methods.map((x) => { - console.log("providers", newProviderStatus); const prov = newProviderStatus[x.url] as AuthenticationProviderStatusOk; return { cost: prov.methods.find((m) => m.type === x.escrow_type)?.usage_fee!, @@ -750,25 +810,92 @@ async function tryRecoverSecret( return { ...state }; } -async function solveChallenge( +/** + * Re-check the status of challenges that are solved asynchronously. + */ +async function pollChallenges( state: ReducerStateRecovery, - ta: ActionArgsSolveChallengeRequest, + args: void, ): Promise<ReducerStateRecovery | ReducerStateError> { - const recDoc: RecoveryDocument = state.verbatim_recovery_document!; - const truth = recDoc.escrow_methods.find( - (x) => x.uuid === state.selected_challenge_uuid, - ); - if (!truth) { - throw "truth for challenge not found"; + for (const truthUuid in state.challenge_feedback) { + if (state.recovery_state === RecoveryStates.RecoveryFinished) { + break; + } + const feedback = state.challenge_feedback[truthUuid]; + const truth = state.verbatim_recovery_document!.escrow_methods.find( + (x) => x.uuid === truthUuid, + ); + if (!truth) { + logger.warn( + "truth for challenge feedback entry not found in recovery document", + ); + continue; + } + if (feedback.state === ChallengeFeedbackStatus.AuthIban) { + const s2 = await requestTruth(state, truth, { + pin: feedback.answer_code, + }); + if (s2.recovery_state) { + state = s2; + } + } } + return state; +} +/** + * Request a truth, optionally with a challenge solution + * provided by the user. + */ +async function requestTruth( + state: ReducerStateRecovery, + truth: EscrowMethod, + solveRequest?: ActionArgsSolveChallengeRequest, +): Promise<ReducerStateRecovery | ReducerStateError> { const url = new URL(`/truth/${truth.uuid}`, truth.url); - // FIXME: This isn't correct for non-question truth responses. - url.searchParams.set( - "response", - await secureAnswerHash(ta.answer, truth.uuid, truth.truth_salt), - ); + if (solveRequest) { + logger.info(`handling solve request ${j2s(solveRequest)}`); + let respHash: string; + switch (truth.escrow_type) { + case ChallengeType.Question: { + if ("answer" in solveRequest) { + respHash = await secureAnswerHash( + solveRequest.answer, + truth.uuid, + truth.truth_salt, + ); + } else { + throw Error("unsupported answer request"); + } + break; + } + case ChallengeType.Email: + case ChallengeType.Sms: + case ChallengeType.Post: + case ChallengeType.Iban: + case ChallengeType.Totp: { + if ("answer" in solveRequest) { + const s = solveRequest.answer.trim().replace(/^A-/, ""); + let pin: number; + try { + pin = Number.parseInt(s); + } catch (e) { + throw Error("invalid pin format"); + } + respHash = await pinAnswerHash(pin); + } else if ("pin" in solveRequest) { + respHash = await pinAnswerHash(solveRequest.pin); + } else { + throw Error("unsupported answer request"); + } + break; + } + default: + throw Error(`unsupported challenge type "${truth.escrow_type}""`); + } + url.searchParams.set("response", respHash); + } const resp = await fetch(url.href, { headers: { @@ -776,60 +903,140 @@ async function solveChallenge( }, }); - console.log(resp); + logger.info( + `got GET /truth response from ${truth.url}, http status ${resp.status}`, + ); - if (resp.status !== 200) { - return { - code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, - hint: "got non-200 response", - http_status: resp.status, - } as ReducerStateError; - } + if (resp.status === HttpStatusCode.Ok) { + let answerSalt: string | undefined = undefined; + if ( + solveRequest && + truth.escrow_type === "question" && + "answer" in solveRequest + ) { + answerSalt = solveRequest.answer; + } - const answerSalt = truth.escrow_type === "question" ? ta.answer : undefined; + const userId = await userIdentifierDerive( + state.identity_attributes, + truth.provider_salt, + ); - const userId = await userIdentifierDerive( - state.identity_attributes, - truth.provider_salt, - ); + const respBody = new Uint8Array(await resp.arrayBuffer()); + const keyShare = await decryptKeyShare( + encodeCrock(respBody), + userId, + answerSalt, + ); - const respBody = new Uint8Array(await resp.arrayBuffer()); - const keyShare = await decryptKeyShare( - encodeCrock(respBody), - userId, - answerSalt, - ); + const recoveredKeyShares = { + ...(state.recovered_key_shares ?? {}), + [truth.uuid]: keyShare, + }; - const recoveredKeyShares = { - ...(state.recovered_key_shares ?? {}), - [truth.uuid]: keyShare, - }; + const challengeFeedback: { [x: string]: ChallengeFeedback } = { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.Solved, + }, + }; - const challengeFeedback = { - ...state.challenge_feedback, - [truth.uuid]: { - state: "solved", - }, - }; + const newState: ReducerStateRecovery = { + ...state, + recovery_state: RecoveryStates.ChallengeSelecting, + challenge_feedback: challengeFeedback, + recovered_key_shares: recoveredKeyShares, + }; - const newState: ReducerStateRecovery = { - ...state, - recovery_state: RecoveryStates.ChallengeSelecting, - challenge_feedback: challengeFeedback, - recovered_key_shares: recoveredKeyShares, - }; + return tryRecoverSecret(newState); + } + + if (resp.status === HttpStatusCode.Forbidden) { + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + challenge_feedback: { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.Message, + message: "Challenge should be solved", + }, + }, + }; + } + + if (resp.status === HttpStatusCode.Accepted) { + const body = await resp.json(); + logger.info(`got body ${j2s(body)}`); + if (body.method === "iban") { + const b = body as IbanExternalAuthResponse; + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + challenge_feedback: { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.AuthIban, + answer_code: b.answer_code, + business_name: b.details.business_name, + challenge_amount: b.details.challenge_amount, + credit_iban: b.details.credit_iban, + wire_transfer_subject: b.details.wire_transfer_subject, + details: b.details, + method: "iban", + }, + }, + }; + } else { + return { + code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, + hint: "unknown external authentication method", + http_status: resp.status, + } as ReducerStateError; + } + } - return tryRecoverSecret(newState); + return { + code: TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_FAILED, + hint: "got unexpected /truth/ response status", + http_status: resp.status, + } as ReducerStateError; +} + +async function solveChallenge( + state: ReducerStateRecovery, + ta: ActionArgsSolveChallengeRequest, +): Promise<ReducerStateRecovery | ReducerStateError> { + const recDoc: RecoveryDocument = state.verbatim_recovery_document!; + const truth = recDoc.escrow_methods.find( + (x) => x.uuid === state.selected_challenge_uuid, + ); + if (!truth) { + throw Error("truth for challenge not found"); + } + return requestTruth(state, truth, ta); } async function recoveryEnterUserAttributes( state: ReducerStateRecovery, - attributes: Record<string, string>, + args: ActionArgsEnterUserAttributes, ): Promise<ReducerStateRecovery | ReducerStateError> { // FIXME: validate attributes const st: ReducerStateRecovery = { ...state, - identity_attributes: attributes, + identity_attributes: args.identity_attributes, + }; + return downloadPolicy(st); +} + +async function changeVersion( + state: ReducerStateRecovery, + args: ActionArgsChangeVersion, +): Promise<ReducerStateRecovery | ReducerStateError> { + const st: ReducerStateRecovery = { + ...state, + selected_version: args.version, + selected_provider_url: args.provider_url, }; return downloadPolicy(st); } @@ -844,369 +1051,457 @@ async function selectChallenge( throw "truth for challenge not found"; } - const url = new URL(`/truth/${truth.uuid}`, truth.url); + return requestTruth({ ...state, selected_challenge_uuid: ta.uuid }, truth); +} - const resp = await fetch(url.href, { - headers: { - "Anastasis-Truth-Decryption-Key": truth.truth_key, +async function backupSelectContinent( + state: ReducerStateBackup, + args: ActionArgsSelectContinent, +): Promise<ReducerStateBackup | ReducerStateError> { + const countries = getCountries(args.continent); + if (countries.length <= 0) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "continent not found", + }; + } + return { + ...state, + backup_state: BackupStates.CountrySelecting, + countries, + selected_continent: args.continent, + }; +} + +async function recoverySelectContinent( + state: ReducerStateRecovery, + args: ActionArgsSelectContinent, +): Promise<ReducerStateRecovery | ReducerStateError> { + const countries = getCountries(args.continent); + return { + ...state, + recovery_state: RecoveryStates.CountrySelecting, + countries, + selected_continent: args.continent, + }; +} + +interface TransitionImpl<S, T> { + argCodec: Codec<T>; + handler: (s: S, args: T) => Promise<S | ReducerStateError>; +} + +interface Transition<S, T> { + [x: string]: TransitionImpl<S, T>; +} + +function transition<S, T>( + action: string, + argCodec: Codec<T>, + handler: (s: S, args: T) => Promise<S | ReducerStateError>, +): Transition<S, T> { + return { + [action]: { + argCodec, + handler, }, - }); + }; +} - console.log(resp); +function transitionBackupJump( + action: string, + st: BackupStates, +): Transition<ReducerStateBackup, void> { + return { + [action]: { + argCodec: codecForAny(), + handler: async (s, a) => ({ ...s, backup_state: st }), + }, + }; +} +function transitionRecoveryJump( + action: string, + st: RecoveryStates, +): Transition<ReducerStateRecovery, void> { + return { + [action]: { + argCodec: codecForAny(), + handler: async (s, a) => ({ ...s, recovery_state: st }), + }, + }; +} + +async function addAuthentication( + state: ReducerStateBackup, + args: ActionArgsAddAuthentication, +): Promise<ReducerStateBackup> { return { ...state, - recovery_state: RecoveryStates.ChallengeSolving, - selected_challenge_uuid: ta.uuid, + authentication_methods: [ + ...(state.authentication_methods ?? []), + args.authentication_method, + ], }; } -export async function reduceAction( - state: ReducerState, - action: string, - args: any, -): Promise<ReducerState> { - console.log(`ts reducer: handling action ${action}`); - if (state.backup_state === BackupStates.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, - backup_state: BackupStates.CountrySelecting, - countries: getCountries(continent), - selected_continent: continent, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - if (state.backup_state === BackupStates.CountrySelecting) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.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 backupSelectCountry(state, countryCode, currencies); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } - if (state.backup_state === BackupStates.UserAttributesCollecting) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.CountrySelecting, - }; - } else if (action === "enter_user_attributes") { - const ta = args as ActionArgEnterUserAttributes; - return backupEnterUserAttributes(state, ta.identity_attributes); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; +async function deleteAuthentication( + state: ReducerStateBackup, + args: ActionArgsDeleteAuthentication, +): Promise<ReducerStateBackup> { + const m = state.authentication_methods ?? []; + m.splice(args.authentication_method, 1); + return { + ...state, + authentication_methods: m, + }; +} + +async function deletePolicy( + state: ReducerStateBackup, + args: ActionArgsDeletePolicy, +): Promise<ReducerStateBackup> { + const policies = [...(state.policies ?? [])]; + policies.splice(args.policy_index, 1); + return { + ...state, + policies, + }; +} + +async function updatePolicy( + state: ReducerStateBackup, + args: ActionArgsUpdatePolicy, +): Promise<ReducerStateBackup> { + const policies = [...(state.policies ?? [])]; + policies[args.policy_index] = { methods: args.policy }; + return { + ...state, + policies, + }; +} + +async function addPolicy( + state: ReducerStateBackup, + args: ActionArgsAddPolicy, +): Promise<ReducerStateBackup> { + return { + ...state, + policies: [ + ...(state.policies ?? []), + { + methods: args.policy, + }, + ], + }; +} + +async function nextFromAuthenticationsEditing( + state: ReducerStateBackup, + args: {}, +): Promise<ReducerStateBackup | ReducerStateError> { + const methods = state.authentication_methods ?? []; + const providers: ProviderInfo[] = []; + for (const provUrl of Object.keys(state.authentication_providers ?? {})) { + const prov = state.authentication_providers![provUrl]; + if ("error_code" in prov) { + continue; } - } - if (state.backup_state === BackupStates.AuthenticationsEditing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.UserAttributesCollecting, - }; - } else if (action === "add_authentication") { - const ta = args as ActionArgAddAuthentication; - return { - ...state, - authentication_methods: [ - ...(state.authentication_methods ?? []), - ta.authentication_method, - ], - }; - } else if (action === "delete_authentication") { - const ta = args as ActionArgDeleteAuthentication; - const m = state.authentication_methods ?? []; - m.splice(ta.authentication_method, 1); - return { - ...state, - authentication_methods: m, - }; - } else if (action === "next") { - const methods = state.authentication_methods ?? []; - const providers: ProviderInfo[] = []; - for (const provUrl of Object.keys(state.authentication_providers ?? {})) { - const prov = state.authentication_providers![provUrl]; - if ("error_code" in prov) { - continue; - } - if (!("http_status" in prov && prov.http_status === 200)) { - continue; - } - const methodCost: Record<string, AmountString> = {}; - for (const meth of prov.methods) { - methodCost[meth.type] = meth.usage_fee; - } - providers.push({ - methodCost, - url: provUrl, - }); - } - const pol = suggestPolicies(methods, providers); - console.log("policies", pol); - return { - ...state, - backup_state: BackupStates.PoliciesReviewing, - ...pol, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; + if (!("http_status" in prov && prov.http_status === 200)) { + continue; } - } - if (state.backup_state === BackupStates.PoliciesReviewing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.AuthenticationsEditing, - }; - } else if (action === "delete_policy") { - const ta = args as ActionArgDeletePolicy; - const policies = [...(state.policies ?? [])]; - policies.splice(ta.policy_index, 1); - return { - ...state, - policies, - }; - } else if (action === "next") { - return { - ...state, - backup_state: BackupStates.SecretEditing, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; + const methodCost: Record<string, AmountString> = {}; + for (const meth of prov.methods) { + methodCost[meth.type] = meth.usage_fee; } + providers.push({ + methodCost, + url: provUrl, + }); } - if (state.backup_state === BackupStates.SecretEditing) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.PoliciesReviewing, - }; - } else if (action === "enter_secret_name") { - const ta = args as ActionArgEnterSecretName; - return { - ...state, - secret_name: ta.name, - }; - } else if (action === "enter_secret") { - const ta = args as ActionArgEnterSecret; - return { - ...state, - expiration: ta.expiration, - core_secret: { - mime: ta.secret.mime ?? "text/plain", - value: ta.secret.value, - }, - }; - } else if (action === "next") { - return uploadSecret(state); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } + const pol = suggestPolicies(methods, providers); + return { + ...state, + backup_state: BackupStates.PoliciesReviewing, + ...pol, + }; +} + +async function updateUploadFees( + state: ReducerStateBackup, +): Promise<ReducerStateBackup | ReducerStateError> { + const expiration = state.expiration; + if (!expiration) { + return { ...state }; } - if (state.backup_state === BackupStates.BackupFinished) { - if (action === "back") { - return { - ...state, - backup_state: BackupStates.SecretEditing, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; + logger.info("updating upload fees"); + const feePerCurrency: Record<string, AmountJson> = {}; + const addFee = (x: AmountLike) => { + x = Amounts.jsonifyAmount(x); + feePerCurrency[x.currency] = Amounts.add( + feePerCurrency[x.currency] ?? Amounts.getZero(x.currency), + x, + ).amount; + }; + const years = Duration.toIntegerYears(Duration.getRemaining(expiration)); + logger.info(`computing fees for ${years} years`); + // For now, we compute fees for *all* available providers. + for (const provUrl in state.authentication_providers ?? {}) { + const prov = state.authentication_providers![provUrl]; + if ("annual_fee" in prov) { + const annualFee = Amounts.mult(prov.annual_fee, years).amount; + logger.info(`adding annual fee ${Amounts.stringify(annualFee)}`); + addFee(annualFee); } } - - 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", - }; + const coveredProvTruth = new Set<string>(); + for (const x of state.policies ?? []) { + for (const m of x.methods) { + const prov = state.authentication_providers![ + m.provider + ] as AuthenticationProviderStatusOk; + const authMethod = state.authentication_methods![m.authentication_method]; + const key = `${m.authentication_method}@${m.provider}`; + if (coveredProvTruth.has(key)) { + continue; } - return { - ...state, - recovery_state: RecoveryStates.CountrySelecting, - countries: getCountries(continent), - selected_continent: continent, - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; + logger.info( + `adding cost for auth method ${authMethod.challenge} / "${authMethod.instructions}" at ${m.provider}`, + ); + coveredProvTruth.add(key); + addFee(prov.truth_upload_fee); } } + return { + ...state, + upload_fees: Object.values(feePerCurrency).map((x) => ({ + fee: Amounts.stringify(x), + })), + }; +} - 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}'`, - }; - } - } +async function enterSecret( + state: ReducerStateBackup, + args: ActionArgsEnterSecret, +): Promise<ReducerStateBackup | ReducerStateError> { + return updateUploadFees({ + ...state, + expiration: args.expiration, + core_secret: { + mime: args.secret.mime ?? "text/plain", + value: args.secret.value, + }, + // A new secret invalidates the existing recovery data. + recovery_data: undefined, + }); +} - 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}'`, - }; - } +async function nextFromChallengeSelecting( + state: ReducerStateRecovery, + args: void, +): Promise<ReducerStateRecovery | ReducerStateError> { + const s2 = await tryRecoverSecret(state); + if (s2.recovery_state === RecoveryStates.RecoveryFinished) { + return s2; } + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: "Not enough challenges solved", + }; +} - 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}'`, - }; - } - } +async function enterSecretName( + state: ReducerStateBackup, + args: ActionArgsEnterSecretName, +): Promise<ReducerStateBackup | ReducerStateError> { + return { + ...state, + secret_name: args.name, + }; +} - if (state.recovery_state === RecoveryStates.ChallengeSelecting) { - if (action === "select_challenge") { - const ta: ActionArgsSelectChallenge = args; - return selectChallenge(state, ta); - } else if (action === "back") { - return { - ...state, - recovery_state: RecoveryStates.SecretSelecting, - }; - } else if (action === "next") { - const s2 = await tryRecoverSecret(state); - if (s2.recovery_state === RecoveryStates.RecoveryFinished) { - return s2; - } - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "Not enough challenges solved", - }; - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } +async function updateSecretExpiration( + state: ReducerStateBackup, + args: ActionArgsUpdateExpiration, +): Promise<ReducerStateBackup | ReducerStateError> { + return updateUploadFees({ + ...state, + expiration: args.expiration, + }); +} - if (state.recovery_state === RecoveryStates.ChallengeSolving) { - if (action === "back") { - const ta: ActionArgsSelectChallenge = args; - return { - ...state, - selected_challenge_uuid: undefined, - recovery_state: RecoveryStates.ChallengeSelecting, - }; - } else if (action === "solve_challenge") { - const ta: ActionArgsSolveChallengeRequest = args; - return solveChallenge(state, ta); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; - } - } +const backupTransitions: Record< + BackupStates, + Transition<ReducerStateBackup, any> +> = { + [BackupStates.ContinentSelecting]: { + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + backupSelectContinent, + ), + }, + [BackupStates.CountrySelecting]: { + ...transitionBackupJump("back", BackupStates.ContinentSelecting), + ...transition( + "select_country", + codecForActionArgSelectCountry(), + backupSelectCountry, + ), + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + backupSelectContinent, + ), + }, + [BackupStates.UserAttributesCollecting]: { + ...transitionBackupJump("back", BackupStates.CountrySelecting), + ...transition( + "enter_user_attributes", + codecForActionArgsEnterUserAttributes(), + backupEnterUserAttributes, + ), + }, + [BackupStates.AuthenticationsEditing]: { + ...transitionBackupJump("back", BackupStates.UserAttributesCollecting), + ...transition("add_authentication", codecForAny(), addAuthentication), + ...transition("delete_authentication", codecForAny(), deleteAuthentication), + ...transition("next", codecForAny(), nextFromAuthenticationsEditing), + }, + [BackupStates.PoliciesReviewing]: { + ...transitionBackupJump("back", BackupStates.AuthenticationsEditing), + ...transitionBackupJump("next", BackupStates.SecretEditing), + ...transition("add_policy", codecForActionArgsAddPolicy(), addPolicy), + ...transition("delete_policy", codecForAny(), deletePolicy), + ...transition("update_policy", codecForAny(), updatePolicy), + }, + [BackupStates.SecretEditing]: { + ...transitionBackupJump("back", BackupStates.PoliciesReviewing), + ...transition("next", codecForAny(), uploadSecret), + ...transition("enter_secret", codecForAny(), enterSecret), + ...transition( + "update_expiration", + codecForActionArgsUpdateExpiration(), + updateSecretExpiration, + ), + ...transition("enter_secret_name", codecForAny(), enterSecretName), + }, + [BackupStates.PoliciesPaying]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + ...transition("pay", codecForAny(), uploadSecret), + }, + [BackupStates.TruthsPaying]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + ...transition("pay", codecForAny(), uploadSecret), + }, + [BackupStates.BackupFinished]: { + ...transitionBackupJump("back", BackupStates.SecretEditing), + }, +}; + +const recoveryTransitions: Record< + RecoveryStates, + Transition<ReducerStateRecovery, any> +> = { + [RecoveryStates.ContinentSelecting]: { + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + recoverySelectContinent, + ), + }, + [RecoveryStates.CountrySelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.ContinentSelecting), + ...transition( + "select_country", + codecForActionArgSelectCountry(), + recoverySelectCountry, + ), + ...transition( + "select_continent", + codecForActionArgSelectContinent(), + recoverySelectContinent, + ), + }, + [RecoveryStates.UserAttributesCollecting]: { + ...transitionRecoveryJump("back", RecoveryStates.CountrySelecting), + ...transition( + "enter_user_attributes", + codecForActionArgsEnterUserAttributes(), + recoveryEnterUserAttributes, + ), + }, + [RecoveryStates.SecretSelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting), + ...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting), + ...transition( + "change_version", + codecForActionArgsChangeVersion(), + changeVersion, + ), + }, + [RecoveryStates.ChallengeSelecting]: { + ...transitionRecoveryJump("back", RecoveryStates.SecretSelecting), + ...transition( + "select_challenge", + codecForActionArgsSelectChallenge(), + selectChallenge, + ), + ...transition("poll", codecForAny(), pollChallenges), + ...transition("next", codecForAny(), nextFromChallengeSelecting), + }, + [RecoveryStates.ChallengeSolving]: { + ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting), + ...transition("solve_challenge", codecForAny(), solveChallenge), + }, + [RecoveryStates.ChallengePaying]: {}, + [RecoveryStates.RecoveryFinished]: { + ...transitionRecoveryJump("back", RecoveryStates.ChallengeSelecting), + }, +}; - if (state.recovery_state === RecoveryStates.RecoveryFinished) { - if (action === "back") { - const ta: ActionArgsSelectChallenge = args; - return { - ...state, - selected_challenge_uuid: undefined, - recovery_state: RecoveryStates.ChallengeSelecting, - }; - } else if (action === "solve_challenge") { - const ta: ActionArgsSolveChallengeRequest = args; - return solveChallenge(state, ta); - } else { - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: `Unsupported action '${action}'`, - }; +export async function reduceAction( + state: ReducerState, + action: string, + args: any, +): Promise<ReducerState> { + let h: TransitionImpl<any, any>; + let stateName: string; + if ("backup_state" in state && state.backup_state) { + stateName = state.backup_state; + h = backupTransitions[state.backup_state][action]; + } else if ("recovery_state" in state && state.recovery_state) { + stateName = state.recovery_state; + h = recoveryTransitions[state.recovery_state][action]; + } else { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Invalid state (needs backup_state or recovery_state)`, + }; + } + if (!h) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, + hint: `Unsupported action '${action}' in state '${stateName}'`, + }; + } + let parsedArgs: any; + try { + parsedArgs = h.argCodec.decode(args); + } catch (e: any) { + return { + code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, + hint: "argument validation failed", + message: e.toString(), + }; + } + try { + return await h.handler(state, parsedArgs); + } catch (e) { + logger.error("action handler failed"); + if (e instanceof ReducerError) { + return e.errorJson; } + throw e; } - - return { - code: TalerErrorCode.ANASTASIS_REDUCER_ACTION_INVALID, - hint: "Reducer action invalid", - }; } diff --git a/packages/anastasis-core/src/policy-suggestion.ts b/packages/anastasis-core/src/policy-suggestion.ts new file mode 100644 index 000000000..7eb6c21cc --- /dev/null +++ b/packages/anastasis-core/src/policy-suggestion.ts @@ -0,0 +1,230 @@ +import { AmountString, j2s, Logger } from "@gnu-taler/taler-util"; +import { AuthMethod, Policy, PolicyProvider } from "./reducer-types.js"; + +const logger = new Logger("anastasis-core:policy-suggestion.ts"); + +const maxMethodSelections = 200; +const maxPolicyEvaluations = 10000; + +/** + * Provider information used during provider/method mapping. + */ +export interface ProviderInfo { + url: string; + methodCost: Record<string, AmountString>; +} + +export function suggestPolicies( + methods: AuthMethod[], + providers: ProviderInfo[], +): PolicySelectionResult { + const numMethods = methods.length; + if (numMethods === 0) { + throw Error("no methods"); + } + let numSel: number; + if (numMethods <= 2) { + numSel = numMethods; + } else if (numMethods <= 4) { + numSel = numMethods - 1; + } else if (numMethods <= 6) { + numSel = numMethods - 2; + } else if (numMethods == 7) { + numSel = numMethods - 3; + } else { + numSel = 4; + } + const policies: Policy[] = []; + const selections = enumerateMethodSelections( + numSel, + numMethods, + maxMethodSelections, + ); + logger.info(`selections: ${j2s(selections)}`); + for (const sel of selections) { + const p = assignProviders(policies, methods, providers, sel); + if (p) { + policies.push(p); + } + } + logger.info(`suggesting policies ${j2s(policies)}`); + return { + policies, + policy_providers: providers.map((x) => ({ + provider_url: x.url, + })), + }; +} + +/** + * Assign providers to a method selection. + * + * The evaluation of the assignment is made with respect to + * previously generated policies. + */ +function assignProviders( + existingPolicies: Policy[], + methods: AuthMethod[], + providers: ProviderInfo[], + methodSelection: number[], +): Policy | undefined { + const providerSelections = enumerateProviderMappings( + methodSelection.length, + providers.length, + maxPolicyEvaluations, + ); + + let bestProvSel: ProviderSelection | undefined; + // Number of different providers selected, larger is better + let bestDiversity = 0; + // Number of identical challenges duplicated at different providers, + // smaller is better + let bestDuplication = Number.MAX_SAFE_INTEGER; + + for (const provSel of providerSelections) { + // First, check if selection is even possible with the methods offered + let possible = true; + for (const methIndex in provSel) { + const provIndex = provSel[methIndex]; + const meth = methods[methIndex]; + const prov = providers[provIndex]; + if (!prov.methodCost[meth.type]) { + possible = false; + break; + } + } + if (!possible) { + continue; + } + + // Evaluate diversity, always prefer policies + // that increase diversity. + const providerSet = new Set<string>(); + // The C reducer evaluates diversity only per policy + // for (const pol of existingPolicies) { + // for (const m of pol.methods) { + // providerSet.add(m.provider); + // } + // } + for (const provIndex of provSel) { + const prov = providers[provIndex]; + providerSet.add(prov.url); + } + + const diversity = providerSet.size; + + // Number of providers that each method shows up at. + const provPerMethod: Set<string>[] = []; + for (let i = 0; i < methods.length; i++) { + provPerMethod[i] = new Set<string>(); + } + for (const pol of existingPolicies) { + for (const m of pol.methods) { + provPerMethod[m.authentication_method].add(m.provider); + } + } + for (const methSelIndex in provSel) { + const prov = providers[provSel[methSelIndex]]; + provPerMethod[methodSelection[methSelIndex]].add(prov.url); + } + + let duplication = 0; + for (const provSet of provPerMethod) { + duplication += provSet.size; + } + + logger.info(`diversity ${diversity}, duplication ${duplication}`); + + if (!bestProvSel || diversity > bestDiversity) { + bestProvSel = provSel; + bestDiversity = diversity; + bestDuplication = duplication; + logger.info(`taking based on diversity`); + } else if (diversity == bestDiversity && duplication < bestDuplication) { + bestProvSel = provSel; + bestDiversity = diversity; + bestDuplication = duplication; + logger.info(`taking based on duplication`); + } + // TODO: also evaluate costs + } + + if (!bestProvSel) { + return undefined; + } + + return { + methods: bestProvSel.map((x, i) => ({ + authentication_method: methodSelection[i], + provider: providers[x].url, + })), + }; +} + +/** + * A provider selection maps a method selection index to a provider index. + */ +type ProviderSelection = number[]; + +/** + * Compute provider mappings. + * Enumerates all n-combinations with repetition of m providers. + */ +function enumerateProviderMappings( + n: number, + m: number, + limit?: number, +): ProviderSelection[] { + const selections: ProviderSelection[] = []; + const a = new Array(n); + const sel = (i: number, start: number = 0) => { + if (i === n) { + selections.push([...a]); + return; + } + for (let j = start; j < m; j++) { + a[i] = j; + sel(i + 1, j); + if (limit && selections.length >= limit) { + break; + } + } + }; + sel(0); + return selections; +} + +interface PolicySelectionResult { + policies: Policy[]; + policy_providers: PolicyProvider[]; +} + +type MethodSelection = number[]; + +/** + * Compute method selections. + * Enumerates all n-combinations without repetition of m methods. + */ +function enumerateMethodSelections( + n: number, + m: number, + limit?: number, +): MethodSelection[] { + const selections: MethodSelection[] = []; + const a = new Array(n); + const sel = (i: number, start: number = 0) => { + if (i === n) { + selections.push([...a]); + return; + } + for (let j = start; j < m; j++) { + a[i] = j; + sel(i + 1, j + 1); + if (limit && selections.length >= limit) { + break; + } + } + }; + sel(0); + return selections; +} diff --git a/packages/anastasis-core/src/provider-types.ts b/packages/anastasis-core/src/provider-types.ts index b477c09b9..f4d998e0a 100644 --- a/packages/anastasis-core/src/provider-types.ts +++ b/packages/anastasis-core/src/provider-types.ts @@ -1,4 +1,4 @@ -import { AmountString } from "@gnu-taler/taler-util"; +import { Amounts, AmountString } from "@gnu-taler/taler-util"; export interface EscrowConfigurationResponse { // Protocol identifier, clarifies that this is an Anastasis provider. @@ -72,3 +72,14 @@ export interface TruthUploadRequest { // store the truth? storage_duration_years: number; } + +export interface IbanExternalAuthResponse { + method: "iban"; + answer_code: number; + details: { + challenge_amount: AmountString; + credit_iban: string; + business_name: string; + wire_transfer_subject: string; + }; +} diff --git a/packages/anastasis-core/src/recovery-document-types.ts b/packages/anastasis-core/src/recovery-document-types.ts index 74003ccb1..3dc4481ff 100644 --- a/packages/anastasis-core/src/recovery-document-types.ts +++ b/packages/anastasis-core/src/recovery-document-types.ts @@ -1,5 +1,14 @@ import { TruthKey, TruthSalt, TruthUuid } from "./crypto.js"; +export enum ChallengeType { + Question = "question", + Sms = "sms", + Email = "email", + Post = "post", + Totp = "totp", + Iban = "iban", +} + export interface RecoveryDocument { /** * Human-readable name of the secret @@ -9,7 +18,7 @@ export interface RecoveryDocument { /** * Encrypted core secret. - * + * * Variable-size length, base32-crock encoded. */ encrypted_core_secret: string; @@ -56,7 +65,7 @@ export interface EscrowMethod { /** * Type of the escrow method (e.g. security question, SMS etc.). */ - escrow_type: string; + escrow_type: ChallengeType; /** * UUID of the escrow method. diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 1a443bf9b..0f64be4eb 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -1,4 +1,15 @@ -import { Duration, Timestamp } from "@gnu-taler/taler-util"; +import { + AmountString, + buildCodecForObject, + codecForAny, + codecForList, + codecForNumber, + codecForString, + codecForTimestamp, + Duration, + Timestamp, +} from "@gnu-taler/taler-util"; +import { ChallengeFeedback } from "./challenge-feedback-types.js"; import { KeyShare } from "./crypto.js"; import { RecoveryDocument } from "./recovery-document-types.js"; @@ -23,7 +34,7 @@ export interface Policy { authentication_method: number; provider: string; }[]; -} +} export interface PolicyProvider { provider_url: string; @@ -47,7 +58,7 @@ export interface ReducerStateBackup { code?: undefined; currencies?: string[]; continents?: ContinentInfo[]; - countries?: any; + countries?: CountryInfo[]; identity_attributes?: { [n: string]: string }; authentication_providers?: { [url: string]: AuthenticationProviderStatus }; authentication_methods?: AuthMethod[]; @@ -56,21 +67,53 @@ export interface ReducerStateBackup { selected_country?: string; secret_name?: string; policies?: Policy[]; + + recovery_data?: { + /** + * Map from truth key (`${methodIndex}/${providerUrl}`) to + * the truth metadata. + */ + truth_metadata: Record<string, TruthMetaData>; + recovery_document: RecoveryDocument; + }; + /** * Policy providers are providers that we checked to be functional * and that are actually used in policies. */ policy_providers?: PolicyProvider[]; success_details?: SuccessDetails; + + /** + * Currently requested payments. + * + * List of taler://pay URIs. + * + * FIXME: There should be more information in this, + * including the provider and amount. + */ payments?: string[]; + + /** + * FIXME: Why is this not a map from provider to payto? + */ policy_payment_requests?: { + /** + * FIXME: This is not a payto URI, right?! + */ payto: string; provider: string; }[]; core_secret?: CoreSecret; - expiration?: Duration; + expiration?: Timestamp; + + upload_fees?: { fee: AmountString }[]; + + // FIXME: The payment secrets and pay URIs should + // probably be consolidated into a single field. + truth_upload_payment_secrets?: Record<string, string>; } export interface AuthMethod { @@ -93,6 +136,9 @@ export interface UserAttributeSpec { type: string; uuid: string; widget: string; + optional?: boolean; + "validation-regex": string | undefined; + "validation-logic": string | undefined; } export interface RecoveryInternalData { @@ -126,8 +172,8 @@ export interface ReducerStateRecovery { identity_attributes?: { [n: string]: string }; - continents?: any; - countries?: any; + continents?: ContinentInfo[]; + countries?: CountryInfo[]; selected_continent?: string; selected_country?: string; @@ -148,6 +194,18 @@ export interface ReducerStateRecovery { selected_challenge_uuid?: string; + /** + * Explicitly selected version by the user. + * FIXME: In the C reducer this is called "version". + */ + selected_version?: number; + + /** + * Explicitly selected provider URL by the user. + * FIXME: In the C reducer this is called "provider_url". + */ + selected_provider_url?: string; + challenge_feedback?: { [uuid: string]: ChallengeFeedback }; /** @@ -161,12 +219,35 @@ export interface ReducerStateRecovery { }; authentication_providers?: { [url: string]: AuthenticationProviderStatus }; - - recovery_error?: any; } -export interface ChallengeFeedback { - state: string; +/** + * Truth data as stored in the reducer. + */ +export interface TruthMetaData { + uuid: string; + + key_share: string; + + policy_index: number; + + pol_method_index: number; + + /** + * Nonce used for encrypting the truth. + */ + nonce: string; + + /** + * Key that the truth (i.e. secret question answer, email address, mobile number, ...) + * is encrypted with when stored at the provider. + */ + truth_key: string; + + /** + * Truth-specific salt. + */ + truth_salt: string; } export interface ReducerStateError { @@ -239,11 +320,16 @@ export interface ReducerStateBackupUserAttributesCollecting authentication_providers: { [url: string]: AuthenticationProviderStatus }; } -export interface ActionArgEnterUserAttributes { +export interface ActionArgsEnterUserAttributes { identity_attributes: Record<string, string>; } -export interface ActionArgAddAuthentication { +export const codecForActionArgsEnterUserAttributes = () => + buildCodecForObject<ActionArgsEnterUserAttributes>() + .property("identity_attributes", codecForAny()) + .build("ActionArgsEnterUserAttributes"); + +export interface ActionArgsAddAuthentication { authentication_method: { type: string; instructions: string; @@ -252,32 +338,134 @@ export interface ActionArgAddAuthentication { }; } -export interface ActionArgDeleteAuthentication { +export interface ActionArgsDeleteAuthentication { authentication_method: number; } -export interface ActionArgDeletePolicy { +export interface ActionArgsDeletePolicy { policy_index: number; } -export interface ActionArgEnterSecretName { +export interface ActionArgsEnterSecretName { name: string; } -export interface ActionArgEnterSecret { +export interface ActionArgsEnterSecret { secret: { value: string; mime?: string; }; - expiration: Duration; + expiration: Timestamp; +} + +export interface ActionArgsSelectContinent { + continent: string; +} + +export const codecForActionArgSelectContinent = () => + buildCodecForObject<ActionArgsSelectContinent>() + .property("continent", codecForString()) + .build("ActionArgSelectContinent"); + +export interface ActionArgsSelectCountry { + country_code: string; + currencies: string[]; } export interface ActionArgsSelectChallenge { uuid: string; } -export type ActionArgsSolveChallengeRequest = SolveChallengeAnswerRequest; - +export type ActionArgsSolveChallengeRequest = + | SolveChallengeAnswerRequest + | SolveChallengePinRequest + | SolveChallengeHashRequest; + +/** + * Answer to a challenge. + * + * For "question" challenges, this is a string with the answer. + * + * For "sms" / "email" / "post" this is a numeric code with optionally + * the "A-" prefix. + */ export interface SolveChallengeAnswerRequest { answer: string; } + +/** + * Answer to a challenge that requires a numeric response. + * + * XXX: Should be deprecated in favor of just "answer". + */ +export interface SolveChallengePinRequest { + pin: number; +} + +/** + * Answer to a challenge by directly providing the hash. + * + * XXX: When / why is this even used? + */ +export interface SolveChallengeHashRequest { + /** + * Base32-crock encoded hash code. + */ + hash: string; +} + +export interface PolicyMember { + authentication_method: number; + provider: string; +} + +export interface ActionArgsAddPolicy { + policy: PolicyMember[]; +} + +export interface ActionArgsUpdateExpiration { + expiration: Timestamp; +} + +export interface ActionArgsChangeVersion { + provider_url: string; + version: number; +} + +export interface ActionArgsUpdatePolicy { + policy_index: number; + policy: PolicyMember[]; +} + +export const codecForActionArgsChangeVersion = () => + buildCodecForObject<ActionArgsChangeVersion>() + .property("provider_url", codecForString()) + .property("version", codecForNumber()) + .build("ActionArgsChangeVersion"); + +export const codecForPolicyMember = () => + buildCodecForObject<PolicyMember>() + .property("authentication_method", codecForNumber()) + .property("provider", codecForString()) + .build("PolicyMember"); + +export const codecForActionArgsAddPolicy = () => + buildCodecForObject<ActionArgsAddPolicy>() + .property("policy", codecForList(codecForPolicyMember())) + .build("ActionArgsAddPolicy"); + +export const codecForActionArgsUpdateExpiration = () => + buildCodecForObject<ActionArgsUpdateExpiration>() + .property("expiration", codecForTimestamp) + .build("ActionArgsUpdateExpiration"); + +export const codecForActionArgsSelectChallenge = () => + buildCodecForObject<ActionArgsSelectChallenge>() + .property("uuid", codecForString()) + .build("ActionArgsSelectChallenge"); + +export const codecForActionArgSelectCountry = () => + buildCodecForObject<ActionArgsSelectCountry>() + .property("country_code", codecForString()) + .property("currencies", codecForList(codecForString())) + .build("ActionArgSelectCountry"); diff --git a/packages/anastasis-core/src/validators.ts b/packages/anastasis-core/src/validators.ts new file mode 100644 index 000000000..1c04bfdb3 --- /dev/null +++ b/packages/anastasis-core/src/validators.ts @@ -0,0 +1,28 @@ +function isPrime(num: number): boolean { + for (let i = 2, s = Math.sqrt(num); i <= s; i++) + if (num % i === 0) return false; + return num > 1; +} + +export function AL_NID_check(s: string): boolean { return true } +export function BE_NRN_check(s: string): boolean { return true } +export function CH_AHV_check(s: string): boolean { return true } +export function CZ_BN_check(s: string): boolean { return true } +export function DE_TIN_check(s: string): boolean { return true } +export function DE_SVN_check(s: string): boolean { return true } +export function ES_DNI_check(s: string): boolean { return true } +export function IN_AADHAR_check(s: string): boolean { return true } +export function IT_CF_check(s: string): boolean { + return true +} + +export function XX_SQUARE_check(s: string): boolean { + const n = parseInt(s, 10) + const r = Math.sqrt(n) + return n === r * r; +} +export function XY_PRIME_check(s: string): boolean { + const n = parseInt(s, 10) + return isPrime(n) +} + diff --git a/packages/anastasis-webui/.storybook/preview.js b/packages/anastasis-webui/.storybook/preview.js index 7cb9405ba..9ab4d9404 100644 --- a/packages/anastasis-webui/.storybook/preview.js +++ b/packages/anastasis-webui/.storybook/preview.js @@ -21,6 +21,12 @@ import { h } from 'preact'; export const parameters = { controls: { expanded: true }, + options: { + storySort: (a, b) => { + return (a[1].args.order ?? 0) - (b[1].args.order ?? 0) + // return a[1].kind === b[1].kind ? 0 : a[1].id.localeCompare(b[1].id, undefined, { numeric: true }) + } + }, } export const globalTypes = { diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index 57cfdd8d4..96d2d65f9 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -4,9 +4,9 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "build": "preact build", + "build": "preact build --no-sw --no-esm", "serve": "sirv build --port 8080 --cors --single", - "dev": "preact watch", + "dev": "preact watch --no-sw --no-esm", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "test": "jest ./tests", "build-storybook": "build-storybook", @@ -25,37 +25,40 @@ "dependencies": { "@gnu-taler/taler-util": "workspace:^0.8.3", "anastasis-core": "workspace:^0.0.1", + "date-fns": "2.25.0", "jed": "1.1.1", - "preact": "^10.3.1", - "preact-render-to-string": "^5.1.4", - "preact-router": "^3.2.1" + "preact": "^10.5.15", + "preact-render-to-string": "^5.1.19", + "preact-router": "^3.2.1", + "qrcode-generator": "^1.4.4" }, "devDependencies": { "@creativebulma/bulma-tooltip": "^1.2.0", - "@storybook/addon-a11y": "^6.2.9", - "@storybook/addon-actions": "^6.2.9", - "@storybook/addon-essentials": "^6.2.9", - "@storybook/addon-links": "^6.2.9", - "@storybook/preact": "^6.2.9", + "@storybook/addon-a11y": "^6.3.12", + "@storybook/addon-actions": "^6.3.12", + "@storybook/addon-essentials": "^6.3.12", + "@storybook/addon-links": "^6.3.12", + "@storybook/preact": "^6.3.12", "@storybook/preset-scss": "^1.0.3", - "@types/enzyme": "^3.10.5", - "@types/jest": "^26.0.8", - "@typescript-eslint/eslint-plugin": "^2.25.0", - "@typescript-eslint/parser": "^2.25.0", + "@types/enzyme": "^3.10.10", + "@types/jest": "^27.0.2", + "@typescript-eslint/eslint-plugin": "^5.3.0", + "@typescript-eslint/parser": "^5.3.0", "bulma": "^0.9.3", "bulma-checkbox": "^1.1.1", "bulma-radio": "^1.1.1", "enzyme": "^3.11.0", - "enzyme-adapter-preact-pure": "^3.1.0", - "eslint": "^6.8.0", - "eslint-config-preact": "^1.1.1", - "jest": "^26.2.2", - "jest-preset-preact": "^4.0.2", - "preact-cli": "^3.2.2", - "sass": "^1.32.13", - "sass-loader": "^10.1.1", - "sirv-cli": "^1.0.0-next.3", - "typescript": "^3.7.5" + "enzyme-adapter-preact-pure": "^3.2.0", + "eslint": "^8.1.0", + "eslint-config-preact": "^1.2.0", + "jest": "^27.3.1", + "jest-preset-preact": "^4.0.5", + "jssha": "^3.2.0", + "preact-cli": "^3.3.1", + "sass": "1.32.13", + "sass-loader": "^10", + "sirv-cli": "^1.0.14", + "typescript": "^4.4.4" }, "jest": { "preset": "jest-preset-preact", diff --git a/packages/anastasis-webui/src/assets/empty.png b/packages/anastasis-webui/src/assets/empty.png Binary files differnew file mode 100644 index 000000000..5120d3138 --- /dev/null +++ b/packages/anastasis-webui/src/assets/empty.png diff --git a/packages/anastasis-webui/src/assets/example/id1.jpg b/packages/anastasis-webui/src/assets/example/id1.jpg Binary files differnew file mode 100644 index 000000000..5d022a379 --- /dev/null +++ b/packages/anastasis-webui/src/assets/example/id1.jpg diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/email.svg b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg new file mode 100644 index 000000000..3e44b8779 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/email.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M22 6c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6zm-2 0l-8 5-8-5h16zm0 12H4V8l8 5 8-5v10z"/></svg>
\ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg new file mode 100644 index 000000000..3787b8350 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/postal.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0z" fill="none"/><path d="M17 15h2v2h-2zM17 11h2v2h-2zM17 7h2v2h-2zM13.74 7l1.26.84V7z"/><path d="M10 3v1.51l2 1.33V5h9v14h-4v2h6V3z"/><path d="M8.17 5.7L15 10.25V21H1V10.48L8.17 5.7zM10 19h3v-7.84L8.17 8.09 3 11.38V19h3v-6h4v6z"/></svg>
\ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/question.svg b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg new file mode 100644 index 000000000..a346556b2 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/question.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M11 23.59v-3.6c-5.01-.26-9-4.42-9-9.49C2 5.26 6.26 1 11.5 1S21 5.26 21 10.5c0 4.95-3.44 9.93-8.57 12.4l-1.43.69zM11.5 3C7.36 3 4 6.36 4 10.5S7.36 18 11.5 18H13v2.3c3.64-2.3 6-6.08 6-9.8C19 6.36 15.64 3 11.5 3zm-1 11.5h2v2h-2zm2-1.5h-2c0-3.25 3-3 3-5 0-1.1-.9-2-2-2s-2 .9-2 2h-2c0-2.21 1.79-4 4-4s4 1.79 4 4c0 2.5-3 2.75-3 5z"/></svg>
\ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg new file mode 100644 index 000000000..ed15679bf --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/sms.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 1.01L7 1c-1.1 0-1.99.9-1.99 2v18c0 1.1.89 2 1.99 2h10c1.1 0 2-.9 2-2V3c0-1.1-.9-1.99-2-1.99zM17 19H7V5h10v14z"/></svg>
\ No newline at end of file diff --git a/packages/anastasis-webui/src/assets/icons/auth_method/video.svg b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg new file mode 100644 index 000000000..69de5e0b4 --- /dev/null +++ b/packages/anastasis-webui/src/assets/icons/auth_method/video.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" enable-background="new 0 0 24 24" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000"><g><rect fill="none" height="24" width="24"/></g><g><g><path d="M18,10.48V6c0-1.1-0.9-2-2-2H4C2.9,4,2,4.9,2,6v12c0,1.1,0.9,2,2,2h12c1.1,0,2-0.9,2-2v-4.48l4,3.98v-11L18,10.48z M16,9.69V18H4V6h12V9.69z"/><circle cx="10" cy="10" r="2"/><path d="M14,15.43c0-0.81-0.48-1.53-1.22-1.85C11.93,13.21,10.99,13,10,13c-0.99,0-1.93,0.21-2.78,0.58C6.48,13.9,6,14.62,6,15.43 V16h8V15.43z"/></g></g></svg>
\ No newline at end of file diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx new file mode 100644 index 000000000..92bef2219 --- /dev/null +++ b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -0,0 +1,49 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ComponentChildren, h, VNode } from "preact"; +// import { LoadingModal } from "../modal"; +import { useAsync } from "../hooks/async"; +// import { Translate } from "../../i18n"; + +type Props = { + children: ComponentChildren; + disabled?: boolean; + onClick?: () => Promise<void>; + [rest: string]: any; +}; + +export function AsyncButton({ onClick, disabled, children, ...rest }: Props): VNode { + const { isLoading, request } = useAsync(onClick); + + // if (isSlow) { + // return <LoadingModal onCancel={cancel} />; + // } + if (isLoading) { + return <button class="button">Loading...</button>; + } + + return <span data-tooltip={rest['data-tooltip']} style={{marginLeft: 5}}> + <button {...rest} onClick={request} disabled={disabled}> + {children} + </button> + </span>; +} diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx new file mode 100644 index 000000000..c916020d7 --- /dev/null +++ b/packages/anastasis-webui/src/components/Notifications.tsx @@ -0,0 +1,59 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, VNode } from "preact"; + +export interface Notification { + message: string; + description?: string | VNode; + type: MessageType; +} + +export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' + +interface Props { + notifications: Notification[]; + removeNotification?: (n: Notification) => void; +} + +function messageStyle(type: MessageType): string { + switch (type) { + case "INFO": return "message is-info"; + case "WARN": return "message is-warning"; + case "ERROR": return "message is-danger"; + case "SUCCESS": return "message is-success"; + default: return "message" + } +} + +export function Notifications({ notifications, removeNotification }: Props): VNode { + return <div class="block"> + {notifications.map((n,i) => <article key={i} class={messageStyle(n.type)}> + <div class="message-header"> + <p>{n.message}</p> + <button class="delete" onClick={() => removeNotification && removeNotification(n)} /> + </div> + {n.description && <div class="message-body"> + {n.description} + </div>} + </article>)} + </div> +}
\ No newline at end of file diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx new file mode 100644 index 000000000..48f1a7c12 --- /dev/null +++ b/packages/anastasis-webui/src/components/QR.tsx @@ -0,0 +1,35 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { h, VNode } from "preact"; +import { useEffect, useRef } from "preact/hooks"; +import qrcode from "qrcode-generator"; + +export function QR({ text }: { text: string }): VNode { + const divRef = useRef<HTMLDivElement>(null); + useEffect(() => { + const qr = qrcode(0, 'L'); + qr.addData(text); + qr.make(); + if (divRef.current) divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); + }); + + return <div style={{ width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center' }}> + <div style={{ width: '50%', minWidth: 200, maxWidth: 300 }} ref={divRef} /> + </div>; +} diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx new file mode 100644 index 000000000..3148c953f --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -0,0 +1,74 @@ +import { format, isAfter, parse, sub, subYears } from "date-fns"; +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { DatePicker } from "../picker/DatePicker"; + +export interface DateInputProps { + label: string; + grabFocus?: boolean; + tooltip?: string; + error?: string; + years?: Array<number>; + bind: [string, (x: string) => void]; +} + +export function DateInput(props: DateInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const [opened, setOpened] = useState(false) + + const value = props.bind[0] || ""; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + + const calendar = subYears(new Date(), 30) + + return <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control"> + <div class="field has-addons"> + <p class="control"> + <input + type="text" + class={showError ? 'input is-danger' : 'input'} + value={value} + onInput={(e) => { + const text = e.currentTarget.value + setDirty(true) + props.bind[1](text); + }} + ref={inputRef} /> + </p> + <p class="control"> + <a class="button" onClick={() => { setOpened(true) }}> + <span class="icon"><i class="mdi mdi-calendar" /></span> + </a> + </p> + </div> + </div> + <p class="help">Using the format yyyy-mm-dd</p> + {showError && <p class="help is-danger">{props.error}</p>} + <DatePicker + opened={opened} + initialDate={calendar} + years={props.years} + closeFunction={() => setOpened(false)} + dateReceiver={(d) => { + setDirty(true) + const v = format(d, 'yyyy-MM-dd') + props.bind[1](v); + }} + /> + </div> + ; + +} diff --git a/packages/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx new file mode 100644 index 000000000..e21418fea --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx @@ -0,0 +1,44 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function EmailInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (<div class="field"> + <label class="label"> + {props.label} + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control has-icons-right"> + <input + value={value} + required + placeholder={props.placeholder} + type="email" + class={showError ? 'input is-danger' : 'input'} + onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx new file mode 100644 index 000000000..8b144ea43 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { TextInputProps } from "./TextInput"; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 + +export function FileInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + + const value = props.bind[0]; + // const [dirty, setDirty] = useState(false) + const image = useRef<HTMLInputElement>(null) + const [sizeError, setSizeError] = useState(false) + function onChange(v: string): void { + // setDirty(true); + props.bind[1](v); + } + return <div class="field"> + <label class="label"> + <a onClick={() => image.current?.click()}> + {props.label} + </a> + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control"> + <input + ref={image} style={{ display: 'none' }} + type="file" name={String(name)} + onChange={e => { + const f: FileList | null = e.currentTarget.files + if (!f || f.length != 1) { + return onChange("") + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange("") + } + setSizeError(false) + return f[0].arrayBuffer().then(b => { + const b64 = btoa( + new Uint8Array(b) + .reduce((data, byte) => data + String.fromCharCode(byte), '') + ) + return onChange(`data:${f[0].type};base64,${b64}` as any) + }) + }} /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && <p class="help is-danger"> + File should be smaller than 1 MB + </p>} + </div> + </div> +} + diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx new file mode 100644 index 000000000..d5bf643d4 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx @@ -0,0 +1,81 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import emptyImage from "../../assets/empty.png"; +import { TextInputProps } from "./TextInput"; + +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024 + +export function ImageInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + + const value = props.bind[0]; + // const [dirty, setDirty] = useState(false) + const image = useRef<HTMLInputElement>(null) + const [sizeError, setSizeError] = useState(false) + function onChange(v: string): void { + // setDirty(true); + props.bind[1](v); + } + return <div class="field"> + <label class="label"> + {props.label} + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control"> + <img src={!value ? emptyImage : value} style={{ width: 200, height: 200 }} onClick={() => image.current?.click()} /> + <input + ref={image} style={{ display: 'none' }} + type="file" name={String(name)} + onChange={e => { + const f: FileList | null = e.currentTarget.files + if (!f || f.length != 1) { + return onChange(emptyImage) + } + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true) + return onChange(emptyImage) + } + setSizeError(false) + return f[0].arrayBuffer().then(b => { + const b64 = btoa( + new Uint8Array(b) + .reduce((data, byte) => data + String.fromCharCode(byte), '') + ) + return onChange(`data:${f[0].type};base64,${b64}` as any) + }) + }} /> + {props.error && <p class="help is-danger">{props.error}</p>} + {sizeError && <p class="help is-danger"> + Image should be smaller than 1 MB + </p>} + </div> + </div> +} + diff --git a/packages/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx new file mode 100644 index 000000000..2afb242b8 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx @@ -0,0 +1,43 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function NumberInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (<div class="field"> + <label class="label"> + {props.label} + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control has-icons-right"> + <input + value={value} + type="number" + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx new file mode 100644 index 000000000..c093689c5 --- /dev/null +++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx @@ -0,0 +1,42 @@ +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; + +export interface TextInputProps { + label: string; + grabFocus?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + bind: [string, (x: string) => void]; +} + +export function TextInput(props: TextInputProps): VNode { + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + if (props.grabFocus) { + inputRef.current?.focus(); + } + }, [props.grabFocus]); + const value = props.bind[0]; + const [dirty, setDirty] = useState(false) + const showError = dirty && props.error + return (<div class="field"> + <label class="label"> + {props.label} + {props.tooltip && <span class="icon has-tooltip-right" data-tooltip={props.tooltip}> + <i class="mdi mdi-information" /> + </span>} + </label> + <div class="control has-icons-right"> + <input + value={value} + placeholder={props.placeholder} + class={showError ? 'input is-danger' : 'input'} + onInput={(e) => {setDirty(true); props.bind[1]((e.target as HTMLInputElement).value)}} + ref={inputRef} + style={{ display: "block" }} /> + </div> + {showError && <p class="help is-danger">{props.error}</p>} + </div> + ); +} diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx index e1bb4c7c0..935951ab9 100644 --- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx +++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -49,7 +49,7 @@ export function NavigationBar({ onMobileMenu, title }: Props): VNode { </a> <div class="navbar-end"> <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> - <LangSelector /> + {/* <LangSelector /> */} </div> </div> </div> diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx index df582a5d0..72655662f 100644 --- a/packages/anastasis-webui/src/components/menu/SideBar.tsx +++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -33,14 +33,15 @@ interface Props { export function Sidebar({ mobile }: Props): VNode { // const config = useConfigContext(); const config = { version: 'none' } + // FIXME: add replacement for __VERSION__ with the current version const process = { env: { __VERSION__: '0.0.0' } } const reducer = useAnastasisContext()! return ( <aside class="aside is-placed-left is-expanded"> - {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}> + {/* {mobile && <div class="footer" onClick={(e) => { return e.stopImmediatePropagation() }}> <LangSelector /> - </div>} + </div>} */} <div class="aside-tools"> <div class="aside-tools-label"> <div><b>Anastasis</b> Reducer</div> @@ -59,97 +60,84 @@ export function Sidebar({ mobile }: Props): VNode { {!reducer.currentReducerState && <li> <div class="ml-4"> - <span class="menu-item-label"><Translate>Start one options</Translate></span> + <span class="menu-item-label"><Translate>Select one option</Translate></span> </div> </li> } {reducer.currentReducerState && reducer.currentReducerState.backup_state ? <Fragment> - <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting ? 'is-active' : ''}> + <li class={reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || + reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>Continent selection</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>Country selection</Translate></span> + <span class="menu-item-label"><Translate>Location</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.UserAttributesCollecting ? 'is-active' : ''}> <div class="ml-4"> - - <span class="menu-item-label"><Translate>User attributes</Translate></span> + <span class="menu-item-label"><Translate>Personal information</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.AuthenticationsEditing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>Auth methods</Translate></span> + <span class="menu-item-label"><Translate>Authorization methods</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesReviewing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>PoliciesReviewing</Translate></span> + <span class="menu-item-label"><Translate>Policies</Translate></span> </div> </li> <li class={reducer.currentReducerState.backup_state === BackupStates.SecretEditing ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>SecretEditing</Translate></span> + <span class="menu-item-label"><Translate>Secret input</Translate></span> </div> </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>PoliciesPaying</Translate></span> + <span class="menu-item-label"><Translate>Payment (optional)</Translate></span> </div> - </li> + </li> */} <li class={reducer.currentReducerState.backup_state === BackupStates.BackupFinished ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>BackupFinished</Translate></span> + <span class="menu-item-label"><Translate>Backup completed</Translate></span> </div> </li> - <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>TruthsPaying</Translate></span> + <span class="menu-item-label"><Translate>Truth Paying</Translate></span> </div> - </li> + </li> */} </Fragment> : (reducer.currentReducerState && reducer.currentReducerState?.recovery_state && <Fragment> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting ? 'is-active' : ''}> + <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting || + reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>TruthsPaying</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>CountrySelecting</Translate></span> + <span class="menu-item-label"><Translate>Location</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.UserAttributesCollecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>UserAttributesCollecting</Translate></span> + <span class="menu-item-label"><Translate>Personal information</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.SecretSelecting ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>SecretSelecting</Translate></span> - </div> - </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting ? 'is-active' : ''}> - <div class="ml-4"> - <span class="menu-item-label"><Translate>ChallengeSelecting</Translate></span> + <span class="menu-item-label"><Translate>Secret selection</Translate></span> </div> </li> - <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}> + <li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting || + reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSolving ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>ChallengeSolving</Translate></span> + <span class="menu-item-label"><Translate>Solve Challenges</Translate></span> </div> </li> <li class={reducer.currentReducerState.recovery_state === RecoveryStates.RecoveryFinished ? 'is-active' : ''}> <div class="ml-4"> - <span class="menu-item-label"><Translate>RecoveryFinished</Translate></span> + <span class="menu-item-label"><Translate>Secret recovered</Translate></span> </div> </li> </Fragment>)} diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx new file mode 100644 index 000000000..eb5d8145d --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DatePicker.tsx @@ -0,0 +1,326 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, Component } from "preact"; + +interface Props { + closeFunction?: () => void; + dateReceiver?: (d: Date) => void; + initialDate?: Date; + years?: Array<number>; + opened?: boolean; +} +interface State { + displayedMonth: number; + displayedYear: number; + selectYearMode: boolean; + currentDate: Date; +} +const now = new Date() + +const monthArrShortFull = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December' +] + +const monthArrShort = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec' +] + +const dayArr = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat' +] + +const yearArr: number[] = [] + + +// inspired by https://codepen.io/m4r1vs/pen/MOOxyE +export class DatePicker extends Component<Props, State> { + + closeDatePicker() { + this.props.closeFunction && this.props.closeFunction(); // Function gets passed by parent + } + + /** + * Gets fired when a day gets clicked. + * @param {object} e The event thrown by the <span /> element clicked + */ + dayClicked(e: any) { + + const element = e.target; // the actual element clicked + + if (element.innerHTML === '') return false; // don't continue if <span /> empty + + // get date from clicked element (gets attached when rendered) + const date = new Date(element.getAttribute('data-value')); + + // update the state + this.setState({ currentDate: date }); + this.passDateToParent(date) + } + + /** + * returns days in month as array + * @param {number} month the month to display + * @param {number} year the year to display + */ + getDaysByMonth(month: number, year: number) { + + const calendar = []; + + const date = new Date(year, month, 1); // month to display + + const firstDay = new Date(year, month, 1).getDay(); // first weekday of month + const lastDate = new Date(year, month + 1, 0).getDate(); // last date of month + + let day: number | null = 0; + + // the calendar is 7*6 fields big, so 42 loops + for (let i = 0; i < 42; i++) { + + if (i >= firstDay && day !== null) day = day + 1; + if (day !== null && day > lastDate) day = null; + + // append the calendar Array + calendar.push({ + day: (day === 0 || day === null) ? null : day, // null or number + date: (day === 0 || day === null) ? null : new Date(year, month, day), // null or Date() + today: (day === now.getDate() && month === now.getMonth() && year === now.getFullYear()) // boolean + }); + } + + return calendar; + } + + /** + * Display previous month by updating state + */ + displayPrevMonth() { + if (this.state.displayedMonth <= 0) { + this.setState({ + displayedMonth: 11, + displayedYear: this.state.displayedYear - 1 + }); + } + else { + this.setState({ + displayedMonth: this.state.displayedMonth - 1 + }); + } + } + + /** + * Display next month by updating state + */ + displayNextMonth() { + if (this.state.displayedMonth >= 11) { + this.setState({ + displayedMonth: 0, + displayedYear: this.state.displayedYear + 1 + }); + } + else { + this.setState({ + displayedMonth: this.state.displayedMonth + 1 + }); + } + } + + /** + * Display the selected month (gets fired when clicking on the date string) + */ + displaySelectedMonth() { + if (this.state.selectYearMode) { + this.toggleYearSelector(); + } + else { + if (!this.state.currentDate) return false; + this.setState({ + displayedMonth: this.state.currentDate.getMonth(), + displayedYear: this.state.currentDate.getFullYear() + }); + } + } + + toggleYearSelector() { + this.setState({ selectYearMode: !this.state.selectYearMode }); + } + + changeDisplayedYear(e: any) { + const element = e.target; + this.toggleYearSelector(); + this.setState({ displayedYear: parseInt(element.innerHTML, 10), displayedMonth: 0 }); + } + + /** + * Pass the selected date to parent when 'OK' is clicked + */ + passSavedDateDateToParent() { + this.passDateToParent(this.state.currentDate) + } + passDateToParent(date: Date) { + if (typeof this.props.dateReceiver === 'function') this.props.dateReceiver(date); + this.closeDatePicker(); + } + + componentDidUpdate() { + // if (this.state.selectYearMode) { + // document.getElementsByClassName('selected')[0].scrollIntoView(); // works in every browser incl. IE, replace with scrollIntoViewIfNeeded when browsers support it + // } + } + + constructor(props: any) { + super(props); + + this.closeDatePicker = this.closeDatePicker.bind(this); + this.dayClicked = this.dayClicked.bind(this); + this.displayNextMonth = this.displayNextMonth.bind(this); + this.displayPrevMonth = this.displayPrevMonth.bind(this); + this.getDaysByMonth = this.getDaysByMonth.bind(this); + this.changeDisplayedYear = this.changeDisplayedYear.bind(this); + this.passDateToParent = this.passDateToParent.bind(this); + this.toggleYearSelector = this.toggleYearSelector.bind(this); + this.displaySelectedMonth = this.displaySelectedMonth.bind(this); + + const initial = props.initialDate || now; + + this.state = { + currentDate: initial, + displayedMonth: initial.getMonth(), + displayedYear: initial.getFullYear(), + selectYearMode: false + } + } + + render() { + + const { currentDate, displayedMonth, displayedYear, selectYearMode } = this.state; + + return ( + <div> + <div class={`datePicker ${ this.props.opened && "datePicker--opened"}`}> + + <div class="datePicker--titles"> + <h3 style={{ + color: selectYearMode ? 'rgba(255,255,255,.87)' : 'rgba(255,255,255,.57)' + }} onClick={this.toggleYearSelector}>{currentDate.getFullYear()}</h3> + <h2 style={{ + color: !selectYearMode ? 'rgba(255,255,255,.87)' : 'rgba(255,255,255,.57)' + }} onClick={this.displaySelectedMonth}> + {dayArr[currentDate.getDay()]}, {monthArrShort[currentDate.getMonth()]} {currentDate.getDate()} + </h2> + </div> + + {!selectYearMode && <nav> + <span onClick={this.displayPrevMonth} class="icon"><i style={{ transform: 'rotate(180deg)' }} class="mdi mdi-forward" /></span> + <h4>{monthArrShortFull[displayedMonth]} {displayedYear}</h4> + <span onClick={this.displayNextMonth} class="icon"><i class="mdi mdi-forward" /></span> + </nav>} + + <div class="datePicker--scroll"> + + {!selectYearMode && <div class="datePicker--calendar" > + + <div class="datePicker--dayNames"> + {['S', 'M', 'T', 'W', 'T', 'F', 'S'].map((day,i) => <span key={i}>{day}</span>)} + </div> + + <div onClick={this.dayClicked} class="datePicker--days"> + + {/* + Loop through the calendar object returned by getDaysByMonth(). + */} + + {this.getDaysByMonth(this.state.displayedMonth, this.state.displayedYear) + .map( + day => { + let selected = false; + + if (currentDate && day.date) selected = (currentDate.toLocaleDateString() === day.date.toLocaleDateString()); + + return (<span key={day.day} + class={(day.today ? 'datePicker--today ' : '') + (selected ? 'datePicker--selected' : '')} + disabled={!day.date} + data-value={day.date} + > + {day.day} + </span>) + } + ) + } + + </div> + + </div>} + + {selectYearMode && <div class="datePicker--selectYear"> + {(this.props.years || yearArr).map(year => ( + <span key={year} class={(year === displayedYear) ? 'selected' : ''} onClick={this.changeDisplayedYear}> + {year} + </span> + ))} + + </div>} + + </div> + </div> + + <div class="datePicker--background" onClick={this.closeDatePicker} style={{ + display: this.props.opened ? 'block' : 'none', + }} + /> + + </div> + ) + } +} + + +for (let i = 2010; i <= now.getFullYear() + 10; i++) { + yearArr.push(i); +} diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx new file mode 100644 index 000000000..275c80fa6 --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx @@ -0,0 +1,50 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, FunctionalComponent } from 'preact'; +import { useState } from 'preact/hooks'; +import { DurationPicker as TestedComponent } from './DurationPicker'; + + +export default { + title: 'Components/Picker/Duration', + component: TestedComponent, + argTypes: { + onCreate: { action: 'onCreate' }, + goBack: { action: 'goBack' }, + } +}; + +function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) { + const r = (args: any) => <Component {...args} /> + r.args = props + return r +} + +export const Example = createExample(TestedComponent, { + days: true, minutes: true, hours: true, seconds: true, + value: 10000000 +}); + +export const WithState = () => { + const [v,s] = useState<number>(1000000) + return <TestedComponent value={v} onChange={s} days minutes hours seconds /> +} diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx new file mode 100644 index 000000000..235a63e2d --- /dev/null +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx @@ -0,0 +1,154 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useTranslator } from "../../i18n"; +import "../../scss/DurationPicker.scss"; + +export interface Props { + hours?: boolean; + minutes?: boolean; + seconds?: boolean; + days?: boolean; + onChange: (value: number) => void; + value: number +} + +// inspiration taken from https://github.com/flurmbo/react-duration-picker +export function DurationPicker({ days, hours, minutes, seconds, onChange, value }: Props): VNode { + const ss = 1000 + const ms = ss * 60 + const hs = ms * 60 + const ds = hs * 24 + const i18n = useTranslator() + + return <div class="rdp-picker"> + {days && <DurationColumn unit={i18n`days`} max={99} + value={Math.floor(value / ds)} + onDecrease={value >= ds ? () => onChange(value - ds) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ds) : undefined} + onChange={diff => onChange(value + diff * ds)} + />} + {hours && <DurationColumn unit={i18n`hours`} max={23} min={1} + value={Math.floor(value / hs) % 24} + onDecrease={value >= hs ? () => onChange(value - hs) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + hs) : undefined} + onChange={diff => onChange(value + diff * hs)} + />} + {minutes && <DurationColumn unit={i18n`minutes`} max={59} min={1} + value={Math.floor(value / ms) % 60} + onDecrease={value >= ms ? () => onChange(value - ms) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ms) : undefined} + onChange={diff => onChange(value + diff * ms)} + />} + {seconds && <DurationColumn unit={i18n`seconds`} max={59} + value={Math.floor(value / ss) % 60} + onDecrease={value >= ss ? () => onChange(value - ss) : undefined} + onIncrease={value < 99 * ds ? () => onChange(value + ss) : undefined} + onChange={diff => onChange(value + diff * ss)} + />} + </div> +} + +interface ColProps { + unit: string, + min?: number, + max: number, + value: number, + onIncrease?: () => void; + onDecrease?: () => void; + onChange?: (diff: number) => void; +} + +function InputNumber({ initial, onChange }: { initial: number, onChange: (n: number) => void }) { + const [value, handler] = useState<{v:string}>({ + v: toTwoDigitString(initial) + }) + + return <input + value={value.v} + onBlur={(e) => onChange(parseInt(value.v, 10))} + onInput={(e) => { + e.preventDefault() + const n = Number.parseInt(e.currentTarget.value, 10); + if (isNaN(n)) return handler({v:toTwoDigitString(initial)}) + return handler({v:toTwoDigitString(n)}) + }} + style={{ width: 50, border: 'none', fontSize: 'inherit', background: 'inherit' }} /> +} + +function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onChange }: ColProps): VNode { + + const cellHeight = 35 + return ( + <div class="rdp-column-container"> + <div class="rdp-masked-div"> + <hr class="rdp-reticule" style={{ top: cellHeight * 2 - 1 }} /> + <hr class="rdp-reticule" style={{ top: cellHeight * 3 - 1 }} /> + + <div class="rdp-column" style={{ top: 0 }}> + + <div class="rdp-cell" key={value - 2}> + {onDecrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} + onClick={onDecrease}> + <span class="icon"> + <i class="mdi mdi-chevron-up" /> + </span> + </button>} + </div> + <div class="rdp-cell" key={value - 1}> + {value > min ? toTwoDigitString(value - 1) : ''} + </div> + <div class="rdp-cell rdp-center" key={value}> + {onChange ? + <InputNumber initial={value} onChange={(n) => onChange(n - value)} /> : + toTwoDigitString(value) + } + <div>{unit}</div> + </div> + + <div class="rdp-cell" key={value + 1}> + {value < max ? toTwoDigitString(value + 1) : ''} + </div> + + <div class="rdp-cell" key={value + 2}> + {onIncrease && <button style={{ width: '100%', textAlign: 'center', margin: 5 }} + onClick={onIncrease}> + <span class="icon"> + <i class="mdi mdi-chevron-down" /> + </span> + </button>} + </div> + + </div> + </div> + </div> + ); +} + + +function toTwoDigitString(n: number) { + if (n < 10) { + return `0${n}`; + } + return `${n}`; +}
\ No newline at end of file diff --git a/packages/anastasis-webui/src/declaration.d.ts b/packages/anastasis-webui/src/declaration.d.ts index b32fb70fc..2c4b7cb3a 100644 --- a/packages/anastasis-webui/src/declaration.d.ts +++ b/packages/anastasis-webui/src/declaration.d.ts @@ -10,8 +10,11 @@ declare module '*.jpeg' { const content: any; export default content; } +declare module '*.png' { + const content: any; + export default content; +} declare module 'jed' { const x: any; export = x; - } -
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/hooks/async.ts b/packages/anastasis-webui/src/hooks/async.ts new file mode 100644 index 000000000..ea3ff6acf --- /dev/null +++ b/packages/anastasis-webui/src/hooks/async.ts @@ -0,0 +1,77 @@ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ +import { useState } from "preact/hooks"; +// import { cancelPendingRequest } from "./backend"; + +export interface Options { + slowTolerance: number; +} + +export interface AsyncOperationApi<T> { + request: (...a: any) => void; + cancel: () => void; + data: T | undefined; + isSlow: boolean; + isLoading: boolean; + error: string | undefined; +} + +export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }): AsyncOperationApi<T> { + const [data, setData] = useState<T | undefined>(undefined); + const [isLoading, setLoading] = useState<boolean>(false); + const [error, setError] = useState<any>(undefined); + const [isSlow, setSlow] = useState(false) + + const request = async (...args: any) => { + if (!fn) return; + setLoading(true); + const handler = setTimeout(() => { + setSlow(true) + }, tooLong) + + try { + console.log("calling async", args) + const result = await fn(...args); + console.log("async back", result) + setData(result); + } catch (error) { + setError(error); + } + setLoading(false); + setSlow(false) + clearTimeout(handler) + }; + + function cancel() { + // cancelPendingRequest() + setLoading(false); + setSlow(false) + } + + return { + request, + cancel, + data, + isSlow, + isLoading, + error + }; +} diff --git a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts index 72594749d..1ef28a168 100644 --- a/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts +++ b/packages/anastasis-webui/src/hooks/use-anastasis-reducer.ts @@ -1,5 +1,12 @@ import { TalerErrorCode } from "@gnu-taler/taler-util"; -import { BackupStates, getBackupStartState, getRecoveryStartState, RecoveryStates, reduceAction, ReducerState } from "anastasis-core"; +import { + BackupStates, + getBackupStartState, + getRecoveryStartState, + RecoveryStates, + reduceAction, + ReducerState, +} from "anastasis-core"; import { useState } from "preact/hooks"; const reducerBaseUrl = "http://localhost:5000/"; @@ -98,13 +105,15 @@ export interface AnastasisReducerApi { startBackup: () => void; startRecover: () => void; reset: () => void; - back: () => void; - transition(action: string, args: any): void; + back: () => Promise<void>; + transition(action: string, args: any): Promise<void>; /** * Run multiple reducer steps in a transaction without * affecting the UI-visible transition state in-between. */ - runTransaction(f: (h: ReducerTransactionHandle) => Promise<void>): void; + runTransaction( + f: (h: ReducerTransactionHandle) => Promise<void>, + ): Promise<void>; } function storageGet(key: string): string | null { @@ -222,9 +231,9 @@ export function useAnastasisReducer(): AnastasisReducerApi { } }, transition(action: string, args: any) { - doTransition(action, args); + return doTransition(action, args); }, - back() { + async back() { const reducerState = anastasisState.reducerState; if (!reducerState) { return; @@ -239,7 +248,7 @@ export function useAnastasisReducer(): AnastasisReducerApi { reducerState: undefined, }); } else { - doTransition("back", {}); + await doTransition("back", {}); } }, dismissError() { @@ -252,30 +261,27 @@ export function useAnastasisReducer(): AnastasisReducerApi { reducerState: undefined, }); }, - runTransaction(f) { - async function run() { - const txHandle = new ReducerTxImpl(anastasisState.reducerState!); - try { - await f(txHandle); - } catch (e) { - console.log("exception during reducer transaction", e); - } - const s = txHandle.transactionState; - console.log("transaction finished, new state", s); - if (s.code !== undefined) { - setAnastasisState({ - ...anastasisState, - currentError: txHandle.transactionState, - }); - } else { - setAnastasisState({ - ...anastasisState, - reducerState: txHandle.transactionState, - currentError: undefined, - }); - } + async runTransaction(f) { + const txHandle = new ReducerTxImpl(anastasisState.reducerState!); + try { + await f(txHandle); + } catch (e) { + console.log("exception during reducer transaction", e); + } + const s = txHandle.transactionState; + console.log("transaction finished, new state", s); + if (s.code !== undefined) { + setAnastasisState({ + ...anastasisState, + currentError: txHandle.transactionState, + }); + } else { + setAnastasisState({ + ...anastasisState, + reducerState: txHandle.transactionState, + currentError: undefined, + }); } - run(); }, }; } diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx new file mode 100644 index 000000000..43807fefe --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx @@ -0,0 +1,50 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ReducerState } from 'anastasis-core'; +import { createExample, reducerStatesExample } from '../../utils'; +import { AddingProviderScreen as TestedComponent } from './AddingProviderScreen'; + + +export default { + title: 'Pages/backup/AddingProviderScreen', + component: TestedComponent, + args: { + order: 4, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +export const NewProvider = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, +} as ReducerState); + +export const NewSMSProvider = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, +} as ReducerState, { providerType: 'sms'}); + +export const NewIBANProvider = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, +} as ReducerState, { providerType: 'iban' }); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx new file mode 100644 index 000000000..9c83da49e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx @@ -0,0 +1,101 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { TextInput } from "../../components/fields/TextInput"; +import { authMethods, KnownAuthMethods } from "./authMethod"; +import { AnastasisClientFrame } from "./index"; + +interface Props { + providerType?: KnownAuthMethods; + cancel: () => void; +} +export function AddingProviderScreen({ providerType, cancel }: Props): VNode { + const [providerURL, setProviderURL] = useState(""); + const [error, setError] = useState<string | undefined>() + const providerLabel = providerType ? authMethods[providerType].label : undefined + + function testProvider(): void { + setError(undefined) + + fetch(`${providerURL}/config`) + .then(r => r.json().catch(d => ({}))) + .then(r => { + if (!("methods" in r) || !Array.isArray(r.methods)) { + setError("This provider doesn't have authentication method. Check the provider URL") + return; + } + if (!providerLabel) { + setError("") + return + } + let found = false + for (let i = 0; i < r.methods.length && !found; i++) { + found = r.methods[i].type !== providerType + } + if (!found) { + setError(`This provider does not support authentication method ${providerLabel}`) + } + }) + .catch(e => { + setError(`There was an error testing this provider, try another one. ${e.message}`) + }) + + } + function addProvider(): void { + // addAuthMethod({ + // authentication_method: { + // type: "sms", + // instructions: `SMS to ${providerURL}`, + // challenge: encodeCrock(stringToBytes(providerURL)), + // }, + // }); + } + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + + let errors = !providerURL ? 'Add provider URL' : undefined + try { + new URL(providerURL) + } catch { + errors = 'Check the URL' + } + if (!!error && !errors) { + errors = error + } + + return ( + <AnastasisClientFrame hideNav + title={!providerLabel ? `Backup: Adding a provider` : `Backup: Adding a ${providerLabel} provider`} + hideNext={errors}> + <div> + <p> + Add a provider url {errors} + </p> + <div class="container"> + <TextInput + label="Provider URL" + placeholder="https://provider.com" + grabFocus + bind={[providerURL, setProviderURL]} /> + </div> + {!!error && <p class="block has-text-danger">{error}</p>} + {error === "" && <p class="block has-text-success">This provider worked!</p>} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={testProvider}>TEST</button> + </div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addProvider}>Add</button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx index d28a6df43..549686616 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx @@ -28,36 +28,103 @@ import { AttributeEntryScreen as TestedComponent } from './AttributeEntryScreen' export default { title: 'Pages/AttributeEntryScreen', component: TestedComponent, + args: { + order: 4, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, }, }; -export const WithSomeAttributes = createExample(TestedComponent, { - ...reducerStatesExample.attributeEditing, +export const Backup = createExample(TestedComponent, { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: [{ + name: 'first name', + label: 'first', + type: 'string', + uuid: 'asdasdsa1', + widget: 'wid', + }, { + name: 'last name', + label: 'second', + type: 'string', + uuid: 'asdasdsa2', + widget: 'wid', + }, { + name: 'birthdate', + label: 'birthdate', + type: 'date', + uuid: 'asdasdsa3', + widget: 'calendar', + }] +} as ReducerState); + +export const Recovery = createExample(TestedComponent, { + ...reducerStatesExample.recoveryAttributeEditing, required_attributes: [{ name: 'first', label: 'first', - type: 'type', + type: 'string', uuid: 'asdasdsa1', widget: 'wid', }, { name: 'pepe', label: 'second', - type: 'type', + type: 'string', uuid: 'asdasdsa2', widget: 'wid', }, { name: 'pepe2', label: 'third', - type: 'type', + type: 'date', uuid: 'asdasdsa3', widget: 'calendar', }] } as ReducerState); -export const Empty = createExample(TestedComponent, { - ...reducerStatesExample.attributeEditing, +export const WithNoRequiredAttribute = createExample(TestedComponent, { + ...reducerStatesExample.backupAttributeEditing, required_attributes: undefined } as ReducerState); + +const allWidgets = [ + "anastasis_gtk_ia_aadhar_in", + "anastasis_gtk_ia_ahv", + "anastasis_gtk_ia_birthdate", + "anastasis_gtk_ia_birthnumber_cz", + "anastasis_gtk_ia_birthnumber_sk", + "anastasis_gtk_ia_birthplace", + "anastasis_gtk_ia_cf_it", + "anastasis_gtk_ia_cpr_dk", + "anastasis_gtk_ia_es_dni", + "anastasis_gtk_ia_es_ssn", + "anastasis_gtk_ia_full_name", + "anastasis_gtk_ia_my_jp", + "anastasis_gtk_ia_nid_al", + "anastasis_gtk_ia_nid_be", + "anastasis_gtk_ia_ssn_de", + "anastasis_gtk_ia_ssn_us", + "anastasis_gtk_ia_tax_de", + "anastasis_gtk_xx_prime", + "anastasis_gtk_xx_square", +] + +function typeForWidget(name: string): string { + if (["anastasis_gtk_xx_prime", + "anastasis_gtk_xx_square", + ].includes(name)) return "number"; + if (["anastasis_gtk_ia_birthdate"].includes(name)) return "date" + return "string"; +} + +export const WithAllPosibleWidget = createExample(TestedComponent, { + ...reducerStatesExample.backupAttributeEditing, + required_attributes: allWidgets.map(w => ({ + name: w, + label: `widget: ${w}`, + type: typeForWidget(w), + uuid: `uuid-${w}`, + widget: w + })) +} as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx index 2f804f940..f86994c97 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -1,10 +1,13 @@ /* eslint-disable @typescript-eslint/camelcase */ -import { h, VNode } from "preact"; +import { UserAttributeSpec, validators } from "anastasis-core"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { ReducerStateRecovery, ReducerStateBackup, UserAttributeSpec } from "anastasis-core/lib"; import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; -import { AnastasisClientFrame, withProcessLabel, LabeledInput } from "./index"; +import { AnastasisClientFrame, withProcessLabel } from "./index"; +import { TextInput } from "../../components/fields/TextInput"; +import { DateInput } from "../../components/fields/DateInput"; +import { NumberInput } from "../../components/fields/NumberInput"; +import { isAfter, parse } from "date-fns"; export function AttributeEntryScreen(): VNode { const reducer = useAnastasisContext() @@ -18,48 +21,139 @@ export function AttributeEntryScreen(): VNode { if (!reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState)) { return <div>invalid state</div> } - + const reqAttr = reducer.currentReducerState.required_attributes || [] + let hasErrors = false; + + const fieldList: VNode[] = reqAttr.map((spec, i: number) => { + const value = attrs[spec.name] + const error = checkIfValid(value, spec) + hasErrors = hasErrors || error !== undefined + return ( + <AttributeEntryField + key={i} + isFirst={i == 0} + setValue={(v: string) => setAttrs({ ...attrs, [spec.name]: v })} + spec={spec} + errorMessage={error} + value={value} /> + ); + }) + return ( <AnastasisClientFrame - title={withProcessLabel(reducer, "Select Country")} + title={withProcessLabel(reducer, "Who are you?")} + hideNext={hasErrors ? "Complete the form." : undefined} onNext={() => reducer.transition("enter_user_attributes", { identity_attributes: attrs, })} > - {reducer.currentReducerState.required_attributes?.map((x, i: number) => { - return ( - <AttributeEntryField - key={i} - isFirst={i == 0} - setValue={(v: string) => setAttrs({ ...attrs, [x.name]: v })} - spec={x} - value={attrs[x.name]} /> - ); - })} + <div class="columns" style={{ maxWidth: 'unset' }}> + <div class="column is-half"> + {fieldList} + </div> + <div class="column is-is-half" > + <p>This personal information will help to locate your secret.</p> + <h1 class="title">This stays private</h1> + <p>The information you have entered here:</p> + <ul> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + Will be hashed, and therefore unreadable + </li> + <li><span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span>The non-hashed version is not shared</li> + </ul> + </div> + </div> </AnastasisClientFrame> ); } -interface AttributeEntryProps { - reducer: AnastasisReducerApi; - reducerState: ReducerStateRecovery | ReducerStateBackup; -} - -export interface AttributeEntryFieldProps { +interface AttributeEntryFieldProps { isFirst: boolean; value: string; setValue: (newValue: string) => void; spec: UserAttributeSpec; + errorMessage: string | undefined; +} +const possibleBirthdayYear: Array<number> = [] +for (let i = 0; i < 100; i++) { + possibleBirthdayYear.push(2020 - i) } +function AttributeEntryField(props: AttributeEntryFieldProps): VNode { -export function AttributeEntryField(props: AttributeEntryFieldProps): VNode { return ( <div> - <LabeledInput - grabFocus={props.isFirst} - label={props.spec.label} - bind={[props.value, props.setValue]} - /> + {props.spec.type === 'date' && + <DateInput + grabFocus={props.isFirst} + label={props.spec.label} + years={possibleBirthdayYear} + error={props.errorMessage} + bind={[props.value, props.setValue]} + />} + {props.spec.type === 'number' && + <NumberInput + grabFocus={props.isFirst} + label={props.spec.label} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + } + {props.spec.type === 'string' && + <TextInput + grabFocus={props.isFirst} + label={props.spec.label} + error={props.errorMessage} + bind={[props.value, props.setValue]} + /> + } + <div class="block"> + This stays private + <span class="icon is-right"> + <i class="mdi mdi-eye-off" /> + </span> + </div> </div> ); } +const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/ + + +function checkIfValid(value: string, spec: UserAttributeSpec): string | undefined { + const pattern = spec['validation-regex'] + if (pattern) { + const re = new RegExp(pattern) + if (!re.test(value)) return 'The value is invalid' + } + const logic = spec['validation-logic'] + if (logic) { + const func = (validators as any)[logic]; + if (func && typeof func === 'function' && !func(value)) return 'Please check the value' + } + const optional = spec.optional + if (!optional && !value) { + return 'This value is required' + } + if ("date" === spec.type) { + if (!YEAR_REGEX.test(value)) { + return "The date doesn't follow the format" + } + + try { + const v = parse(value, 'yyyy-MM-dd', new Date()); + if (Number.isNaN(v.getTime())) { + return "Some numeric values seems out of range for a date" + } + if ("birthdate" === spec.name && isAfter(v, new Date())) { + return "A birthdate cannot be in the future" + } + } catch (e) { + return "Could not parse the date" + } + } + return undefined +} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx deleted file mode 100644 index 9567e0ef7..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodEmailSetup.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "./AuthenticationEditorScreen"; -import { AnastasisClientFrame, LabeledInput } from "./index"; - -export function AuthMethodEmailSetup(props: AuthMethodSetupProps): VNode { - const [email, setEmail] = useState(""); - return ( - <AnastasisClientFrame hideNav title="Add email authentication"> - <p> - For email authentication, you need to provide an email address. When - recovering your secret, you will need to enter the code you receive by - email. - </p> - <div> - <LabeledInput - label="Email address" - grabFocus - bind={[email, setEmail]} /> - </div> - <div> - <button onClick={() => props.cancel()}>Cancel</button> - <button - onClick={() => props.addAuthMethod({ - authentication_method: { - type: "email", - instructions: `Email to ${email}`, - challenge: encodeCrock(stringToBytes(email)), - }, - })} - > - Add - </button> - </div> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx deleted file mode 100644 index 55e37a968..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodPostSetup.tsx +++ /dev/null @@ -1,69 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - canonicalJson, encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "./AuthenticationEditorScreen"; -import { LabeledInput } from "./index"; - -export function AuthMethodPostSetup(props: AuthMethodSetupProps): VNode { - const [fullName, setFullName] = useState(""); - const [street, setStreet] = useState(""); - const [city, setCity] = useState(""); - const [postcode, setPostcode] = useState(""); - const [country, setCountry] = useState(""); - - const addPostAuth = () => { - const challengeJson = { - full_name: fullName, - street, - city, - postcode, - country, - }; - props.addAuthMethod({ - authentication_method: { - type: "email", - instructions: `Letter to address in postal code ${postcode}`, - challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), - }, - }); - }; - - return ( - <div class="home"> - <h1>Add {props.method} authentication</h1> - <div> - <p> - For postal letter authentication, you need to provide a postal - address. When recovering your secret, you will be asked to enter a - code that you will receive in a letter to that address. - </p> - <div> - <LabeledInput - grabFocus - label="Full Name" - bind={[fullName, setFullName]} /> - </div> - <div> - <LabeledInput label="Street" bind={[street, setStreet]} /> - </div> - <div> - <LabeledInput label="City" bind={[city, setCity]} /> - </div> - <div> - <LabeledInput label="Postal Code" bind={[postcode, setPostcode]} /> - </div> - <div> - <LabeledInput label="Country" bind={[country, setCountry]} /> - </div> - <div> - <button onClick={() => props.cancel()}>Cancel</button> - <button onClick={() => addPostAuth()}>Add</button> - </div> - </div> - </div> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx deleted file mode 100644 index 7699cdf34..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodQuestionSetup.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "./AuthenticationEditorScreen"; -import { AnastasisClientFrame, LabeledInput } from "./index"; - -export function AuthMethodQuestionSetup(props: AuthMethodSetupProps): VNode { - const [questionText, setQuestionText] = useState(""); - const [answerText, setAnswerText] = useState(""); - const addQuestionAuth = (): void => props.addAuthMethod({ - authentication_method: { - type: "question", - instructions: questionText, - challenge: encodeCrock(stringToBytes(answerText)), - }, - }); - return ( - <AnastasisClientFrame hideNav title="Add Security Question"> - <div> - <p> - For security question authentication, you need to provide a question - and its answer. When recovering your secret, you will be shown the - question and you will need to type the answer exactly as you typed it - here. - </p> - <div> - <LabeledInput - label="Security question" - grabFocus - bind={[questionText, setQuestionText]} /> - </div> - <div> - <LabeledInput label="Answer" bind={[answerText, setAnswerText]} /> - </div> - <div> - <button onClick={() => props.cancel()}>Cancel</button> - <button onClick={() => addQuestionAuth()}>Add</button> - </div> - </div> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx deleted file mode 100644 index 6f4797275..000000000 --- a/packages/anastasis-webui/src/pages/home/AuthMethodSmsSetup.tsx +++ /dev/null @@ -1,51 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { h, VNode } from "preact"; -import { useState, useRef, useLayoutEffect } from "preact/hooks"; -import { AuthMethodSetupProps } from "./AuthenticationEditorScreen"; -import { AnastasisClientFrame } from "./index"; - -export function AuthMethodSmsSetup(props: AuthMethodSetupProps): VNode { - const [mobileNumber, setMobileNumber] = useState(""); - const addSmsAuth = (): void => { - props.addAuthMethod({ - authentication_method: { - type: "sms", - instructions: `SMS to ${mobileNumber}`, - challenge: encodeCrock(stringToBytes(mobileNumber)), - }, - }); - }; - const inputRef = useRef<HTMLInputElement>(null); - useLayoutEffect(() => { - inputRef.current?.focus(); - }, []); - return ( - <AnastasisClientFrame hideNav title="Add SMS authentication"> - <div> - <p> - For SMS authentication, you need to provide a mobile number. When - recovering your secret, you will be asked to enter the code you - receive via SMS. - </p> - <label> - Mobile number:{" "} - <input - value={mobileNumber} - ref={inputRef} - style={{ display: "block" }} - autoFocus - onChange={(e) => setMobileNumber((e.target as any).value)} - type="text" /> - </label> - <div> - <button onClick={() => props.cancel()}>Cancel</button> - <button onClick={() => addSmsAuth()}>Add</button> - </div> - </div> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx index 44d3795b2..5077c3eb0 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -19,13 +20,17 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { ReducerState } from 'anastasis-core'; import { createExample, reducerStatesExample } from '../../utils'; import { AuthenticationEditorScreen as TestedComponent } from './AuthenticationEditorScreen'; export default { - title: 'Pages/AuthenticationEditorScreen', + title: 'Pages/backup/AuthenticationEditorScreen', component: TestedComponent, + args: { + order: 5, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, @@ -33,3 +38,56 @@ export default { }; export const Example = createExample(TestedComponent, reducerStatesExample.authEditing); +export const OneAuthMethodConfigured = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, + authentication_methods: [{ + type: 'question', + instructions: 'what time is it?', + challenge: 'asd', + }] +} as ReducerState); + + +export const SomeMoreAuthMethodConfigured = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, + authentication_methods: [{ + type: 'question', + instructions: 'what time is it?', + challenge: 'asd', + },{ + type: 'question', + instructions: 'what time is it?', + challenge: 'qwe', + },{ + type: 'sms', + instructions: 'what time is it?', + challenge: 'asd', + },{ + type: 'email', + instructions: 'what time is it?', + challenge: 'asd', + },{ + type: 'email', + instructions: 'what time is it?', + challenge: 'asd', + },{ + type: 'email', + instructions: 'what time is it?', + challenge: 'asd', + },{ + type: 'email', + instructions: 'what time is it?', + challenge: 'asd', + }] +} as ReducerState); + +export const NoAuthMethodProvided = createExample(TestedComponent, { + ...reducerStatesExample.authEditing, + authentication_providers: {}, + authentication_methods: [] +} as ReducerState); + + // type: string; + // instructions: string; + // challenge: string; + // mime_type?: string; diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx index e9ffccbac..93ca81194 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -1,19 +1,21 @@ /* eslint-disable @typescript-eslint/camelcase */ -import { AuthMethod, ReducerStateBackup } from "anastasis-core"; -import { h, VNode } from "preact"; +import { AuthMethod } from "anastasis-core"; +import { ComponentChildren, Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { TextInput } from "../../components/fields/TextInput"; import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisReducerApi } from "../../hooks/use-anastasis-reducer"; -import { AuthMethodEmailSetup } from "./AuthMethodEmailSetup"; -import { AuthMethodPostSetup } from "./AuthMethodPostSetup"; -import { AuthMethodQuestionSetup } from "./AuthMethodQuestionSetup"; -import { AuthMethodSmsSetup } from "./AuthMethodSmsSetup"; +import { authMethods, KnownAuthMethods } from "./authMethod"; import { AnastasisClientFrame } from "./index"; + + +const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T> + export function AuthenticationEditorScreen(): VNode { - const [selectedMethod, setSelectedMethod] = useState<string | undefined>( - undefined - ); + const [noProvidersAck, setNoProvidersAck] = useState(false) + const [selectedMethod, setSelectedMethod] = useState<KnownAuthMethods | undefined>(undefined); + const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined) + const reducer = useAnastasisContext() if (!reducer) { return <div>no reducer in context</div> @@ -21,7 +23,29 @@ export function AuthenticationEditorScreen(): VNode { if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { return <div>invalid state</div> } + const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? []; + const haveMethodsConfigured = configuredAuthMethods.length > 0; + + function removeByIndex(index: number): void { + if (reducer) reducer.transition("delete_authentication", { + authentication_method: index, + }) + } + + const camByType: { [s: string]: AuthMethodWithRemove[] } = {} + for (let index = 0; index < configuredAuthMethods.length; index++) { + const cam = { + ...configuredAuthMethods[index], + remove: () => removeByIndex(index) + } + const prevValue = camByType[cam.type] || [] + prevValue.push(cam) + camByType[cam.type] = prevValue; + } + + const providers = reducer.currentReducerState.authentication_providers!; + const authAvailableSet = new Set<string>(); for (const provKey of Object.keys(providers)) { const p = providers[provKey]; @@ -31,79 +55,125 @@ export function AuthenticationEditorScreen(): VNode { } } } + if (selectedMethod) { const cancel = (): void => setSelectedMethod(undefined); const addMethod = (args: any): void => { reducer.transition("add_authentication", args); setSelectedMethod(undefined); }; - const methodMap: Record< - string, (props: AuthMethodSetupProps) => h.JSX.Element - > = { - sms: AuthMethodSmsSetup, - question: AuthMethodQuestionSetup, - email: AuthMethodEmailSetup, - post: AuthMethodPostSetup, - }; - const AuthSetup = methodMap[selectedMethod] ?? AuthMethodNotImplemented; - return ( + + const AuthSetup = authMethods[selectedMethod].screen ?? AuthMethodNotImplemented; + return (<Fragment> <AuthSetup cancel={cancel} + configured={camByType[selectedMethod] || []} addAuthMethod={addMethod} method={selectedMethod} /> + + {!authAvailableSet.has(selectedMethod) && <ConfirmModal active + onCancel={cancel} description="No providers founds" label="Add a provider manually" + onConfirm={() => { + null + }} + > + We have found no trusted cloud providers for your recovery secret. You can add a provider manually. + To add a provider you must know the provider URL (e.g. https://provider.com) + <p> + <a>More about cloud providers</a> + </p> + </ConfirmModal>} + + </Fragment> ); } - function MethodButton(props: { method: string; label: string }): VNode { + + if (addingProvider !== undefined) { + return <div /> + } + + function MethodButton(props: { method: KnownAuthMethods }): VNode { + if (authMethods[props.method].skip) return <div /> + return ( - <button - disabled={!authAvailableSet.has(props.method)} - onClick={() => { - setSelectedMethod(props.method); - if (reducer) reducer.dismissError(); - }} - > - {props.label} - </button> + <div class="block"> + <button + style={{ justifyContent: 'space-between' }} + class="button is-fullwidth" + onClick={() => { + setSelectedMethod(props.method); + }} + > + <div style={{ display: 'flex' }}> + <span class="icon "> + {authMethods[props.method].icon} + </span> + {authAvailableSet.has(props.method) ? + <span> + Add a {authMethods[props.method].label} challenge + </span> : + <span> + Add a {authMethods[props.method].label} provider + </span> + } + </div> + {!authAvailableSet.has(props.method) && + <span class="icon has-text-danger" > + <i class="mdi mdi-exclamation-thick" /> + </span> + } + {camByType[props.method] && + <span class="tag is-info" > + {camByType[props.method].length} + </span> + } + </button> + </div> ); } - const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? []; - const haveMethodsConfigured = configuredAuthMethods.length; + const errors = !haveMethodsConfigured ? "There is not enough authentication methods." : undefined; return ( - <AnastasisClientFrame title="Backup: Configure Authentication Methods"> - <div> - <MethodButton method="sms" label="SMS" /> - <MethodButton method="email" label="Email" /> - <MethodButton method="question" label="Question" /> - <MethodButton method="post" label="Physical Mail" /> - <MethodButton method="totp" label="TOTP" /> - <MethodButton method="iban" label="IBAN" /> - </div> - <h2>Configured authentication methods</h2> - {haveMethodsConfigured ? ( - configuredAuthMethods.map((x, i) => { - return ( - <p key={i}> - {x.type} ({x.instructions}){" "} - <button - onClick={() => reducer.transition("delete_authentication", { - authentication_method: i, - })} - > - Delete - </button> + <AnastasisClientFrame title="Backup: Configure Authentication Methods" hideNext={errors}> + <div class="columns"> + <div class="column is-half"> + <div> + {getKeys(authMethods).map(method => <MethodButton key={method} method={method} />)} + </div> + {authAvailableSet.size === 0 && <ConfirmModal active={!noProvidersAck} + onCancel={() => setNoProvidersAck(true)} description="No providers founds" label="Add a provider manually" + onConfirm={() => { + null + }} + > + We have found no trusted cloud providers for your recovery secret. You can add a provider manually. + To add a provider you must know the provider URL (e.g. https://provider.com) + <p> + <a>More about cloud providers</a> </p> - ); - }) - ) : ( - <p>No authentication methods configured yet.</p> - )} + </ConfirmModal>} + </div> + <div class="column is-half"> + <p class="block"> + When recovering your wallet, you will be asked to verify your identity via the methods you configure here. + The list of authentication method is defined by the backup provider list. + </p> + <p class="block"> + <button class="button is-info">Manage the backup provider's list</button> + </p> + {authAvailableSet.size > 0 && <p class="block"> + We couldn't find provider for some of the authentication methods. + </p>} + </div> + </div> </AnastasisClientFrame> ); } +type AuthMethodWithRemove = AuthMethod & { remove: () => void } export interface AuthMethodSetupProps { method: string; addAuthMethod: (x: any) => void; + configured: AuthMethodWithRemove[]; cancel: () => void; } @@ -116,8 +186,36 @@ function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { ); } -interface AuthenticationEditorProps { - reducer: AnastasisReducerApi; - backupState: ReducerStateBackup; + +function ConfirmModal({ active, description, onCancel, onConfirm, children, danger, disabled, label = 'Confirm' }: Props): VNode { + return <div class={active ? "modal is-active" : "modal"}> + <div class="modal-background " onClick={onCancel} /> + <div class="modal-card" style={{ maxWidth: 700 }}> + <header class="modal-card-head"> + {!description ? null : <p class="modal-card-title"><b>{description}</b></p>} + <button class="delete " aria-label="close" onClick={onCancel} /> + </header> + <section class="modal-card-body"> + {children} + </section> + <footer class="modal-card-foot"> + <button class="button" onClick={onCancel} >Dismiss</button> + <div class="buttons is-right" style={{ width: '100%' }}> + <button class={danger ? "button is-danger " : "button is-info "} disabled={disabled} onClick={onConfirm} >{label}</button> + </div> + </footer> + </div> + <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> + </div> } +interface Props { + active?: boolean; + description?: string; + onCancel?: () => void; + onConfirm?: () => void; + label?: string; + children?: ComponentChildren; + danger?: boolean; + disabled?: boolean; +} diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx index 65a2b7e13..b71a79727 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx @@ -26,15 +26,18 @@ import { BackupFinishedScreen as TestedComponent } from './BackupFinishedScreen' export default { - title: 'Pages/BackupFinishedScreen', + title: 'Pages/backup/FinishedScreen', component: TestedComponent, + args: { + order: 9, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, }, }; -export const Simple = createExample(TestedComponent, reducerStatesExample.backupFinished); +export const WithoutName = createExample(TestedComponent, reducerStatesExample.backupFinished); export const WithName = createExample(TestedComponent, {...reducerStatesExample.backupFinished, secret_name: 'super_secret', diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx index 218f1d1fd..7938baca4 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -1,3 +1,4 @@ +import { format } from "date-fns"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; @@ -11,23 +12,33 @@ export function BackupFinishedScreen(): VNode { return <div>invalid state</div> } const details = reducer.currentReducerState.success_details - return (<AnastasisClientFrame hideNext title="Backup finished"> - <p> - Your backup of secret "{reducer.currentReducerState.secret_name ?? "??"}" was + + return (<AnastasisClientFrame hideNav title="Backup finished"> + {reducer.currentReducerState.secret_name ? <p> + Your backup of secret <b>"{reducer.currentReducerState.secret_name}"</b> was successful. - </p> - <p>The backup is stored by the following providers:</p> + </p> : + <p> + Your secret was successfully backed up. + </p>} - {details && <ul> + {details && <div class="block"> + <p>The backup is stored by the following providers:</p> {Object.keys(details).map((x, i) => { const sd = details[x]; return ( - <li key={i}> - {x} (Policy version {sd.policy_version}) - </li> + <div key={i} class="box"> + {x} + <p> + version {sd.policy_version} + {sd.policy_expiration.t_ms !== 'never' ? ` expires at: ${format(sd.policy_expiration.t_ms, 'dd-MM-yyyy')}` : ' without expiration date'} + </p> + </div> ); })} - </ul>} - <button onClick={() => reducer.reset()}>Back to start</button> + </div>} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={() => reducer.back()}>Back</button> + </div> </AnastasisClientFrame>); } diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx index 4f186c031..48115c798 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx @@ -16,68 +16,201 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ChallengeOverviewScreen as TestedComponent } from './ChallengeOverviewScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { RecoveryStates, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { ChallengeOverviewScreen as TestedComponent } from "./ChallengeOverviewScreen"; export default { - title: 'Pages/ChallengeOverviewScreen', + title: "Pages/recovery/ChallengeOverviewScreen", component: TestedComponent, + args: { + order: 5, + }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const OneChallenge = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const OneUnsolvedPolicy = createExample(TestedComponent, { + ...reducerStatesExample.challengeSelecting, + recovery_information: { + policies: [[{ uuid: "1" }]], + challenges: [ + { + cost: "USD:1", + instructions: "just go for it", + type: "question", + uuid: "1", + }, + ], + }, +} as ReducerState); + +export const SomePoliciesOneSolved = createExample(TestedComponent, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'1'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '1', - }] + policies: [[{ uuid: "1" }, { uuid: "2" }], [{ uuid: "uuid-3" }]], + challenges: [ + { + cost: "USD:1", + instructions: "this question cost 1 USD", + type: "question", + uuid: "1", + }, + { + cost: "USD:0", + instructions: "answering this question is free", + type: "question", + uuid: "2", + }, + { + cost: "USD:1", + instructions: "this question is already answered", + type: "question", + uuid: "uuid-3", + }, + ], + }, + challenge_feedback: { + "uuid-3": { + state: "solved", + }, }, } as ReducerState); -export const MoreChallenges = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const OneBadConfiguredPolicy = createExample(TestedComponent, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'1'}, {uuid:'2'}],[{uuid:'3'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '1', - },{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '2', - },{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'question', - uuid: '3', - }] + policies: [[{ uuid: "1" }, { uuid: "2" }]], + challenges: [ + { + cost: "USD:1", + instructions: "this policy has a missing uuid (the other auth method)", + type: "totp", + uuid: "1", + }, + ], }, } as ReducerState); -export const OneBadConfiguredPolicy = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting, +export const OnePolicyWithAllTheChallenges = createExample(TestedComponent, { + ...reducerStatesExample.challengeSelecting, recovery_information: { - policies: [[{uuid:'2'}]], - challenges: [{ - cost: 'USD:1', - instructions: 'just go for it', - type: 'sasd', - uuid: '1', - }] + policies: [ + [ + { uuid: "1" }, + { uuid: "2" }, + { uuid: "3" }, + { uuid: "4" }, + { uuid: "5" }, + { uuid: "6" }, + { uuid: "7" }, + { uuid: "8" }, + ], + ], + challenges: [ + { + cost: "USD:1", + instructions: "Does P equals NP?", + type: "question", + uuid: "1", + }, + { + cost: "USD:1", + instructions: "SMS to 555-555", + type: "sms", + uuid: "2", + }, + { + cost: "USD:1", + instructions: "Email to qwe@asd.com", + type: "email", + uuid: "3", + }, + { + cost: "USD:1", + instructions: 'Enter 8 digits code for "Anastasis"', + type: "totp", + uuid: "4", + }, + { + // + cost: "USD:0", + instructions: "Wire transfer from ASDXCVQWE123123 with holder Florian", + type: "iban", + uuid: "5", + }, + { + cost: "USD:1", + instructions: "Join a video call", + type: "video", //Enter 8 digits code for "Anastasis" + uuid: "7", + }, + {}, + { + cost: "USD:1", + instructions: "Letter to address in postal code DE123123", + type: "post", //Enter 8 digits code for "Anastasis" + uuid: "8", + }, + { + cost: "USD:1", + instructions: "instruction for an unknown type of challenge", + type: "new-type-of-challenge", + uuid: "6", + }, + ], }, } as ReducerState); -export const NoPolicies = createExample(TestedComponent, reducerStatesExample.challengeSelecting); +export const OnePolicyWithAllTheChallengesInDifferentState = createExample( + TestedComponent, + { + ...reducerStatesExample.challengeSelecting, + recovery_state: RecoveryStates.ChallengeSelecting, + recovery_information: { + policies: [ + [ + { uuid: "1" }, + { uuid: "2" }, + { uuid: "3" }, + { uuid: "4" }, + { uuid: "5" }, + { uuid: "6" }, + { uuid: "7" }, + { uuid: "8" }, + { uuid: "9" }, + { uuid: "10" }, + ], + ], + challenges: [ + { + cost: "USD:1", + instructions: 'in state "solved"', + type: "question", + uuid: "1", + }, + { + cost: "USD:1", + instructions: 'in state "message"', + type: "question", + uuid: "2", + }, + ], + }, + challenge_feedback: { + 1: { state: "solved" }, + 2: { state: "message", message: "Security question was not solved correctly" }, + // FIXME: add missing feedback states here! + }, + } as ReducerState, +); +export const NoPolicies = createExample( + TestedComponent, + reducerStatesExample.challengeSelecting, +); diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx index c9b52e91b..ed34bbde2 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -1,77 +1,184 @@ +import { ChallengeFeedback, ChallengeFeedbackStatus } from "anastasis-core"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; +import { authMethods, KnownAuthMethods } from "./authMethod"; + +function OverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { + const { feedback } = props; + if (!feedback) { + return null; + } + switch (feedback.state) { + case ChallengeFeedbackStatus.Message: + return ( + <div> + <p>{feedback.message}</p> + </div> + ); + case ChallengeFeedbackStatus.Pending: + case ChallengeFeedbackStatus.AuthIban: + return null; + case ChallengeFeedbackStatus.RateLimitExceeded: + return <div>Rate limit exceeded.</div>; + case ChallengeFeedbackStatus.Redirect: + return <div>Redirect (FIXME: not supported)</div>; + case ChallengeFeedbackStatus.Unsupported: + return <div>Challenge not supported by client.</div>; + case ChallengeFeedbackStatus.TruthUnknown: + return <div>Truth unknown</div>; + default: + return ( + <div> + <pre>{JSON.stringify(feedback)}</pre> + </div> + ); + } +} export function ChallengeOverviewScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.recovery_state === undefined + ) { + return <div>invalid state</div>; } - const policies = reducer.currentReducerState.recovery_information?.policies ?? []; - const chArr = reducer.currentReducerState.recovery_information?.challenges ?? []; - const challengeFeedback = reducer.currentReducerState?.challenge_feedback; + const policies = + reducer.currentReducerState.recovery_information?.policies ?? []; + const knownChallengesArray = + reducer.currentReducerState.recovery_information?.challenges ?? []; + const challengeFeedback = + reducer.currentReducerState?.challenge_feedback ?? {}; - const challenges: { + const knownChallengesMap: { [uuid: string]: { type: string; instructions: string; cost: string; + feedback: ChallengeFeedback | undefined; }; } = {}; - for (const ch of chArr) { - challenges[ch.uuid] = { + for (const ch of knownChallengesArray) { + knownChallengesMap[ch.uuid] = { type: ch.type, cost: ch.cost, instructions: ch.instructions, + feedback: challengeFeedback[ch.uuid], }; } + const policiesWithInfo = policies.map((row) => { + let isPolicySolved = true; + const challenges = row + .map(({ uuid }) => { + const info = knownChallengesMap[uuid]; + const isChallengeSolved = info?.feedback?.state === "solved"; + isPolicySolved = isPolicySolved && isChallengeSolved; + return { info, uuid, isChallengeSolved }; + }) + .filter((ch) => ch.info !== undefined); + + return { isPolicySolved, challenges }; + }); + + const atLeastThereIsOnePolicySolved = + policiesWithInfo.find((p) => p.isPolicySolved) !== undefined; + + const errors = !atLeastThereIsOnePolicySolved + ? "Solve one policy before proceeding" + : undefined; return ( - <AnastasisClientFrame title="Recovery: Solve challenges"> - <h2>Policies</h2> - {!policies.length && <p> - No policies found - </p>} - {policies.map((row, i) => { - return ( - <div key={i}> - <h3>Policy #{i + 1}</h3> - {row.map(column => { - const ch = challenges[column.uuid]; - if (!ch) return <div> - There is no challenge for this policy - </div> - const feedback = challengeFeedback?.[column.uuid]; - return ( - <div key={column.uuid} - style={{ - borderLeft: "2px solid gray", - paddingLeft: "0.5em", - borderRadius: "0.5em", - marginTop: "0.5em", - marginBottom: "0.5em", - }} - > - <h4> - {ch.type} ({ch.instructions}) - </h4> - <p>Status: {feedback?.state ?? "unknown"}</p> - {feedback?.state !== "solved" ? ( - <button - onClick={() => reducer.transition("select_challenge", { - uuid: column.uuid, - })} - > - Solve - </button> - ) : null} + <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges"> + {!policies.length ? ( + <p class="block"> + No policies found, try with another version of the secret + </p> + ) : policies.length === 1 ? ( + <p class="block"> + One policy found for this secret. You need to solve all the challenges + in order to recover your secret. + </p> + ) : ( + <p class="block"> + We have found {policies.length} polices. You need to solve all the + challenges from one policy in order to recover your secret. + </p> + )} + {policiesWithInfo.map((policy, policy_index) => { + const tableBody = policy.challenges.map(({ info, uuid }) => { + const isFree = !info.cost || info.cost.endsWith(":0"); + const method = authMethods[info.type as KnownAuthMethods]; + return ( + <div + key={uuid} + class="block" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div + style={{ + display: "flex", + flexDirection: "column", + }} + > + <div style={{ display: "flex", alignItems: "center" }}> + <span class="icon">{method?.icon}</span> + <span>{info.instructions}</span> </div> - ); - })} + <OverviewFeedbackDisplay feedback={info.feedback} /> + </div> + <div> + {method && info.feedback?.state !== "solved" ? ( + <a + class="button" + onClick={() => + reducer.transition("select_challenge", { uuid }) + } + > + {isFree ? "Solve" : `Pay and Solve`} + </a> + ) : null} + {info.feedback?.state === "solved" ? ( + <a class="button is-success"> Solved </a> + ) : null} + </div> + </div> + ); + }); + + const policyName = policy.challenges + .map((x) => x.info.type) + .join(" + "); + const opa = !atLeastThereIsOnePolicySolved + ? undefined + : policy.isPolicySolved + ? undefined + : "0.6"; + return ( + <div + key={policy_index} + class="box" + style={{ + opacity: opa, + }} + > + <h3 class="subtitle"> + Policy #{policy_index + 1}: {policyName} + </h3> + {policy.challenges.length === 0 && ( + <p>This policy doesn't have challenges.</p> + )} + {policy.challenges.length === 1 && ( + <p>This policy just have one challenge.</p> + )} + {policy.challenges.length > 1 && ( + <p>This policy have {policy.challenges.length} challenges.</p> + )} + {tableBody} </div> ); })} diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx index adf36980f..e5fe09e99 100644 --- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx @@ -20,17 +20,19 @@ */ import { createExample, reducerStatesExample } from '../../utils'; -import { CountrySelectionScreen as TestedComponent } from './CountrySelectionScreen'; +import { ChallengePayingScreen as TestedComponent } from './ChallengePayingScreen'; export default { - title: 'Pages/CountrySelectionScreen', + title: 'Pages/recovery/__ChallengePayingScreen', component: TestedComponent, + args: { + order: 10, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, }, }; -export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry); -export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry); +export const Example = createExample(TestedComponent, reducerStatesExample.challengePaying); diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx new file mode 100644 index 000000000..84896a2ec --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx @@ -0,0 +1,33 @@ +import { h, VNode } from "preact"; +import { useAnastasisContext } from "../../context/anastasis"; +import { AnastasisClientFrame } from "./index"; + +export function ChallengePayingScreen(): VNode { + const reducer = useAnastasisContext() + if (!reducer) { + return <div>no reducer in context</div> + } + if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { + return <div>invalid state</div> + } + const payments = ['']; //reducer.currentReducerState.payments ?? + return ( + <AnastasisClientFrame + hideNav + title="Recovery: Challenge Paying" + > + <p> + Some of the providers require a payment to store the encrypted + authentication information. + </p> + <ul> + {payments.map((x, i) => { + return <li key={i}>{x}</li>; + })} + </ul> + <button onClick={() => reducer.transition("pay", {})}> + Check payment status now + </button> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx index aad37cd7f..6bdb3515d 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -19,18 +20,33 @@ * @author Sebastian Javier Marchano (sebasjm) */ +import { ReducerState } from 'anastasis-core'; import { createExample, reducerStatesExample } from '../../utils'; import { ContinentSelectionScreen as TestedComponent } from './ContinentSelectionScreen'; export default { - title: 'Pages/ContinentSelectionScreen', + title: 'Pages/Location', component: TestedComponent, + args: { + order: 2, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, }, }; -export const Backup = createExample(TestedComponent, reducerStatesExample.backupSelectCountry); -export const Recovery = createExample(TestedComponent, reducerStatesExample.recoverySelectCountry); +export const BackupSelectContinent = createExample(TestedComponent, reducerStatesExample.backupSelectContinent); + +export const BackupSelectCountry = createExample(TestedComponent, { + ...reducerStatesExample.backupSelectContinent, + selected_continent: 'Testcontinent', +} as ReducerState); + +export const RecoverySelectContinent = createExample(TestedComponent, reducerStatesExample.recoverySelectContinent); + +export const RecoverySelectCountry = createExample(TestedComponent, { + ...reducerStatesExample.recoverySelectContinent, + selected_continent: 'Testcontinent', +} as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx index ad529a4a7..0e43f982d 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -1,20 +1,104 @@ +/* eslint-disable @typescript-eslint/camelcase */ import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame, withProcessLabel } from "./index"; export function ContinentSelectionScreen(): VNode { const reducer = useAnastasisContext() + + //FIXME: remove this when #7056 is fixed + const countryFromReducer = (reducer?.currentReducerState as any).selected_country || "" + const [countryCode, setCountryCode] = useState( countryFromReducer ) + if (!reducer || !reducer.currentReducerState || !("continents" in reducer.currentReducerState)) { return <div /> } - const sel = (x: string): void => reducer.transition("select_continent", { continent: x }); + const selectContinent = (continent: string): void => { + reducer.transition("select_continent", { continent }) + }; + const selectCountry = (country: string): void => { + setCountryCode(country) + }; + + + const continentList = reducer.currentReducerState.continents || []; + const countryList = reducer.currentReducerState.countries || []; + const theContinent = reducer.currentReducerState.selected_continent || "" + // const cc = reducer.currentReducerState.selected_country || ""; + const theCountry = countryList.find(c => c.code === countryCode) + const selectCountryAction = () => { + //selection should be when the select box changes it value + if (!theCountry) return; + reducer.transition("select_country", { + country_code: countryCode, + currencies: [theCountry.currency], + }) + } + + // const step1 = reducer.currentReducerState.backup_state === BackupStates.ContinentSelecting || + // reducer.currentReducerState.recovery_state === RecoveryStates.ContinentSelecting; + + const errors = !theCountry ? "Select a country" : undefined + return ( - <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Continent")}> - {reducer.currentReducerState.continents.map((x: any) => ( - <button onClick={() => sel(x.name)} key={x.name}> - {x.name} - </button> - ))} + <AnastasisClientFrame hideNext={errors} title={withProcessLabel(reducer, "Where do you live?")} onNext={selectCountryAction}> + + <div class="columns" > + <div class="column is-one-third"> + <div class="field"> + <label class="label">Continent</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth" > + <select onChange={(e) => selectContinent(e.currentTarget.value)} value={theContinent} > + <option key="none" disabled selected value=""> Choose a continent </option> + {continentList.map(prov => ( + <option key={prov.name} value={prov.name}> + {prov.name} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + + <div class="field"> + <label class="label">Country</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth" > + <select onChange={(e) => selectCountry((e.target as any).value)} disabled={!theContinent} value={theCountry?.code || ""}> + <option key="none" disabled selected value=""> Choose a country </option> + {countryList.map(prov => ( + <option key={prov.name} value={prov.code}> + {prov.name} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + + {/* {theCountry && <div class="field"> + <label class="label">Available currencies:</label> + <div class="control"> + <input class="input is-small" type="text" readonly value={theCountry.currency} /> + </div> + </div>} */} + </div> + <div class="column is-two-third"> + <p> + Your location will help us to determine which personal information + ask you for the next step. + </p> + </div> + </div> + </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx deleted file mode 100644 index 555622c1d..000000000 --- a/packages/anastasis-webui/src/pages/home/CountrySelectionScreen.tsx +++ /dev/null @@ -1,27 +0,0 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { h, VNode } from "preact"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, withProcessLabel } from "./index"; - -export function CountrySelectionScreen(): VNode { - const reducer = useAnastasisContext() - if (!reducer) { - return <div>no reducer in context</div> - } - if (!reducer.currentReducerState || !("countries" in reducer.currentReducerState)) { - return <div>invalid state</div> - } - const sel = (x: any): void => reducer.transition("select_country", { - country_code: x.code, - currencies: [x.currency], - }); - return ( - <AnastasisClientFrame hideNext title={withProcessLabel(reducer, "Select Country")} > - {reducer.currentReducerState.countries.map((x: any) => ( - <button onClick={() => sel(x)} key={x.name}> - {x.name} ({x.currency}) - </button> - ))} - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx new file mode 100644 index 000000000..fc339e48e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx @@ -0,0 +1,109 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { ReducerState } from 'anastasis-core'; +import { createExample, reducerStatesExample } from '../../utils'; +import { EditPoliciesScreen as TestedComponent } from './EditPoliciesScreen'; + + +export default { + title: 'Pages/backup/ReviewPoliciesScreen/EditPoliciesScreen', + args: { + order: 6, + }, + component: TestedComponent, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +export const EditingAPolicy = createExample(TestedComponent, { + ...reducerStatesExample.policyReview, + policies: [{ + methods: [{ + authentication_method: 1, + provider: 'https://anastasis.demo.taler.net/' + }, { + authentication_method: 2, + provider: 'http://localhost:8086/' + }] + }, { + methods: [{ + authentication_method: 1, + provider: 'http://localhost:8086/' + }] + }], + authentication_methods: [{ + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA" + }, { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA" + }, { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "" + }, { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8" + }] +} as ReducerState, { index : 0}); + +export const CreatingAPolicy = createExample(TestedComponent, { + ...reducerStatesExample.policyReview, + policies: [{ + methods: [{ + authentication_method: 1, + provider: 'https://anastasis.demo.taler.net/' + }, { + authentication_method: 2, + provider: 'http://localhost:8086/' + }] + }, { + methods: [{ + authentication_method: 1, + provider: 'http://localhost:8086/' + }] + }], + authentication_methods: [{ + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA" + }, { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA" + }, { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "" + }, { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8" + }] +} as ReducerState, { index : 3}); + diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx new file mode 100644 index 000000000..85cc96c46 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx @@ -0,0 +1,133 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { AuthMethod, Policy } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { useAnastasisContext } from "../../context/anastasis"; +import { authMethods, KnownAuthMethods } from "./authMethod"; +import { AnastasisClientFrame } from "./index"; + +export interface ProviderInfo { + url: string; + cost: string; + isFree: boolean; +} + +export type ProviderInfoByType = { + [type in KnownAuthMethods]?: ProviderInfo[]; +}; + +interface Props { + index: number; + cancel: () => void; + confirm: (changes: MethodProvider[]) => void; + +} + +export interface MethodProvider { + authentication_method: number; + provider: string; +} + +export function EditPoliciesScreen({ index: policy_index, cancel, confirm }: Props): VNode { + const [changedProvider, setChangedProvider] = useState<Array<string>>([]) + + const reducer = useAnastasisContext() + if (!reducer) { + return <div>no reducer in context</div> + } + if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { + return <div>invalid state</div> + } + + const selectableProviders: ProviderInfoByType = {} + const allProviders = Object.entries(reducer.currentReducerState.authentication_providers || {}) + for (let index = 0; index < allProviders.length; index++) { + const [url, status] = allProviders[index] + if ("methods" in status) { + status.methods.map(m => { + const type: KnownAuthMethods = m.type as KnownAuthMethods + const values = selectableProviders[type] || [] + const isFree = !m.usage_fee || m.usage_fee.endsWith(":0") + values.push({ url, cost: m.usage_fee, isFree }) + selectableProviders[type] = values + }) + } + } + + const allAuthMethods = reducer.currentReducerState.authentication_methods ?? []; + const policies = reducer.currentReducerState.policies ?? []; + const policy = policies[policy_index] + + for(let method_index = 0; method_index < allAuthMethods.length; method_index++ ) { + policy?.methods.find(m => m.authentication_method === method_index)?.provider + } + + function sendChanges(): void { + const newMethods: MethodProvider[] = [] + allAuthMethods.forEach((method, index) => { + const oldValue = policy?.methods.find(m => m.authentication_method === index) + if (changedProvider[index] === undefined && oldValue !== undefined) { + newMethods.push(oldValue) + } + if (changedProvider[index] !== undefined && changedProvider[index] !== "") { + newMethods.push({ + authentication_method: index, + provider: changedProvider[index] + }) + } + }) + confirm(newMethods) + } + + return <AnastasisClientFrame hideNav title={!policy ? "Backup: New Policy" : "Backup: Edit Policy"}> + <section class="section"> + {!policy ? <p> + Creating a new policy #{policy_index} + </p> : <p> + Editing policy #{policy_index} + </p>} + {allAuthMethods.map((method, index) => { + //take the url from the updated change or from the policy + const providerURL = changedProvider[index] === undefined ? + policy?.methods.find(m => m.authentication_method === index)?.provider : + changedProvider[index]; + + const type: KnownAuthMethods = method.type as KnownAuthMethods + function changeProviderTo(url: string): void { + const copy = [...changedProvider] + copy[index] = url + setChangedProvider(copy) + } + return ( + <div key={index} class="block" style={{ display: 'flex', alignItems: 'center' }}> + <span class="icon"> + {authMethods[type]?.icon} + </span> + <span> + {method.instructions} + </span> + <span> + <span class="select " > + <select onChange={(e) => changeProviderTo(e.currentTarget.value)} value={providerURL ?? ""}> + <option key="none" value=""> << off >> </option> + {selectableProviders[type]?.map(prov => ( + <option key={prov.url} value={prov.url}> + {prov.url} + </option> + ))} + </select> + </span> + </span> + </div> + ); + })} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span class="buttons"> + <button class="button" onClick={() => setChangedProvider([])}>Reset</button> + <button class="button is-info" onClick={sendChanges}>Confirm</button> + </span> + </div> + </section> + </AnastasisClientFrame> +} diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx index 1a9462b88..e952ab28d 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx @@ -26,8 +26,11 @@ import { PoliciesPayingScreen as TestedComponent } from './PoliciesPayingScreen' export default { - title: 'Pages/PoliciesPayingScreen', + title: 'Pages/backup/PoliciesPayingScreen', component: TestedComponent, + args: { + order: 8, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx index 8a39cf0e4..a470f5155 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -13,7 +13,7 @@ export function PoliciesPayingScreen(): VNode { const payments = reducer.currentReducerState.policy_payment_requests ?? []; return ( - <AnastasisClientFrame hideNext title="Backup: Recovery Document Payments"> + <AnastasisClientFrame hideNav title="Backup: Recovery Document Payments"> <p> Some of the providers require a payment to store the encrypted recovery document. diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx index 0c1842420..0d2ebb778 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx @@ -26,7 +26,10 @@ import { RecoveryFinishedScreen as TestedComponent } from './RecoveryFinishedScr export default { - title: 'Pages/RecoveryFinishedScreen', + title: 'Pages/recovery/FinishedScreen', + args: { + order: 7, + }, component: TestedComponent, argTypes: { onUpdate: { action: 'onUpdate' }, @@ -34,7 +37,7 @@ export default { }, }; -export const NormalEnding = createExample(TestedComponent, { +export const GoodEnding = createExample(TestedComponent, { ...reducerStatesExample.recoveryFinished, core_secret: { mime: 'text/plain', value: 'hello' } } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx index 8c8a2c7c8..a61ef9efa 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -15,20 +15,26 @@ export function RecoveryFinishedScreen(): VNode { if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { return <div>invalid state</div> } - const encodedSecret = reducer.currentReducerState.core_secret?.value + const encodedSecret = reducer.currentReducerState.core_secret if (!encodedSecret) { - return <AnastasisClientFrame title="Recovery Problem" hideNext> + return <AnastasisClientFrame title="Recovery Problem" hideNav> <p> Secret not found </p> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={() => reducer.back()}>Back</button> + </div> </AnastasisClientFrame> } - const secret = bytesToString(decodeCrock(encodedSecret)) + const secret = bytesToString(decodeCrock(encodedSecret.value)) return ( - <AnastasisClientFrame title="Recovery Finished" hideNext> + <AnastasisClientFrame title="Recovery Finished" hideNav> <p> Secret: {secret} </p> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={() => reducer.back()}>Back</button> + </div> </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx index b52699e7b..9f7e26c16 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx @@ -26,7 +26,10 @@ import { ReviewPoliciesScreen as TestedComponent } from './ReviewPoliciesScreen' export default { - title: 'Pages/ReviewPoliciesScreen', + title: 'Pages/backup/ReviewPoliciesScreen', + args: { + order: 6, + }, component: TestedComponent, argTypes: { onUpdate: { action: 'onUpdate' }, @@ -40,11 +43,11 @@ export const HasPoliciesButMethodListIsEmpty = createExample(TestedComponent, { methods: [{ authentication_method: 0, provider: 'asd' - },{ + }, { authentication_method: 1, provider: 'asd' }] - },{ + }, { methods: [{ authentication_method: 1, provider: 'asd' @@ -55,27 +58,191 @@ export const HasPoliciesButMethodListIsEmpty = createExample(TestedComponent, { export const SomePoliciesWithMethods = createExample(TestedComponent, { ...reducerStatesExample.policyReview, - policies: [{ - methods: [{ - authentication_method: 0, - provider: 'asd' - },{ - authentication_method: 1, - provider: 'asd' - }] - },{ - methods: [{ - authentication_method: 1, - provider: 'asd' - }] - }], + policies: [ + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + } + ] + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 0, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 1, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + }, + { + methods: [ + { + authentication_method: 2, + provider: "https://kudos.demo.anastasis.lu/" + }, + { + authentication_method: 3, + provider: "https://anastasis.demo.taler.net/" + }, + { + authentication_method: 4, + provider: "https://anastasis.demo.taler.net/" + } + ] + } + ], authentication_methods: [{ - challenge: 'asd', - instructions: 'ins', - type: 'type', + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA" + }, { + type: "sms", + instructions: "SMS to 555-555", + challenge: "" + }, { + type: "question", + instructions: "Does P equal NP?", + challenge: "C5SP8" },{ - challenge: 'asd2', - instructions: 'ins2', - type: 'type2', - }] + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA" + }, { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "" + }, { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8" +}] } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx index b360ccaf0..f93963f67 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -1,9 +1,13 @@ /* eslint-disable @typescript-eslint/camelcase */ import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis"; +import { authMethods, KnownAuthMethods } from "./authMethod"; +import { EditPoliciesScreen } from "./EditPoliciesScreen"; import { AnastasisClientFrame } from "./index"; export function ReviewPoliciesScreen(): VNode { + const [editingPolicy, setEditingPolicy] = useState<number | undefined>() const reducer = useAnastasisContext() if (!reducer) { return <div>no reducer in context</div> @@ -11,42 +15,72 @@ export function ReviewPoliciesScreen(): VNode { if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { return <div>invalid state</div> } - const authMethods = reducer.currentReducerState.authentication_methods ?? []; + + const configuredAuthMethods = reducer.currentReducerState.authentication_methods ?? []; const policies = reducer.currentReducerState.policies ?? []; + if (editingPolicy !== undefined) { + return ( + <EditPoliciesScreen + index={editingPolicy} + cancel={() => setEditingPolicy(undefined)} + confirm={async (newMethods) => { + await reducer.transition("update_policy", { + policy_index: editingPolicy, + policy: newMethods, + }); + setEditingPolicy(undefined) + }} + /> + ) + } + + const errors = policies.length < 1 ? 'Need more policies' : undefined return ( - <AnastasisClientFrame title="Backup: Review Recovery Policies"> + <AnastasisClientFrame hideNext={errors} title="Backup: Review Recovery Policies"> + {policies.length > 0 && <p class="block"> + Based on your configured authentication method you have created, some policies + have been configured. In order to recover your secret you have to solve all the + challenges of at least one policy. + </p>} + {policies.length < 1 && <p class="block"> + No policies had been created. Go back and add more authentication methods. + </p>} + <div class="block" style={{ justifyContent: 'flex-end' }} > + <button class="button is-success" onClick={() => setEditingPolicy(policies.length + 1)}>Add new policy</button> + </div> {policies.map((p, policy_index) => { const methods = p.methods - .map(x => authMethods[x.authentication_method] && ({ ...authMethods[x.authentication_method], provider: x.provider })) + .map(x => configuredAuthMethods[x.authentication_method] && ({ ...configuredAuthMethods[x.authentication_method], provider: x.provider })) .filter(x => !!x) const policyName = methods.map(x => x.type).join(" + "); return ( - <div key={policy_index} class="policy"> - <h3> - Policy #{policy_index + 1}: {policyName} - </h3> - Required Authentications: - {!methods.length && <p> - No auth method found - </p>} - <ul> + <div key={policy_index} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <div> + <h3 class="subtitle"> + Policy #{policy_index + 1}: {policyName} + </h3> + {!methods.length && <p> + No auth method found + </p>} {methods.map((m, i) => { return ( - <li key={i}> - {m.type} ({m.instructions}) at provider {m.provider} - </li> + <p key={i} class="block" style={{ display: 'flex', alignItems: 'center' }}> + <span class="icon"> + {authMethods[m.type as KnownAuthMethods]?.icon} + </span> + <span> + {m.instructions} recovery provided by <a href={m.provider}>{m.provider}</a> + </span> + </p> ); })} - </ul> - <div> - <button - onClick={() => reducer.transition("delete_policy", { policy_index })} - > - Delete Policy - </button> + </div> + <div style={{ marginTop: 'auto', marginBottom: 'auto', display: 'flex', justifyContent: 'space-between', flexDirection: 'column' }}> + <button class="button is-info block" onClick={() => setEditingPolicy(policy_index)}>Edit</button> + <button class="button is-danger block" onClick={() => reducer.transition("delete_policy", { policy_index })}>Delete</button> </div> </div> ); diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx index 18560356a..49dd8fca8 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx @@ -26,8 +26,11 @@ import { SecretEditorScreen as TestedComponent } from './SecretEditorScreen'; export default { - title: 'Pages/SecretEditorScreen', + title: 'Pages/backup/SecretEditorScreen', component: TestedComponent, + args: { + order: 7, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx index a5235d66c..1b36a1b21 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -4,20 +4,21 @@ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { useAnastasisContext } from "../../context/anastasis"; import { - AnastasisClientFrame, - LabeledInput + AnastasisClientFrame } from "./index"; +import { TextInput } from "../../components/fields/TextInput"; +import { FileInput } from "../../components/fields/FileInput"; export function SecretEditorScreen(): VNode { const reducer = useAnastasisContext() const [secretValue, setSecretValue] = useState(""); - const currentSecretName = reducer?.currentReducerState - && ("secret_name" in reducer.currentReducerState) + const currentSecretName = reducer?.currentReducerState + && ("secret_name" in reducer.currentReducerState) && reducer.currentReducerState.secret_name; const [secretName, setSecretName] = useState(currentSecretName || ""); - + if (!reducer) { return <div>no reducer in context</div> } @@ -25,8 +26,8 @@ export function SecretEditorScreen(): VNode { return <div>invalid state</div> } - const secretNext = (): void => { - reducer.runTransaction(async (tx) => { + const secretNext = async (): Promise<void> => { + return reducer.runTransaction(async (tx) => { await tx.transition("enter_secret_name", { name: secretName, }); @@ -44,21 +45,29 @@ export function SecretEditorScreen(): VNode { }; return ( <AnastasisClientFrame - title="Backup: Provide secret" + title="Backup: Provide secret to backup" onNext={() => secretNext()} > <div> - <LabeledInput - label="Secret Name:" + <TextInput + label="Secret's name:" grabFocus bind={[secretName, setSecretName]} /> </div> <div> - <LabeledInput - label="Secret Value:" + <TextInput + label="Enter the secret as text:" bind={[secretValue, setSecretValue]} /> + <div style={{display:'flex',}}> + or + <FileInput + label="click here" + bind={[secretValue, setSecretValue]} + /> + to import a file + </div> </div> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx index e9c597023..6919eebad 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -26,8 +25,11 @@ import { SecretSelectionScreen as TestedComponent } from './SecretSelectionScree export default { - title: 'Pages/SecretSelectionScreen', + title: 'Pages/recovery/SecretSelectionScreen', component: TestedComponent, + args: { + order: 4, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, @@ -37,7 +39,7 @@ export default { export const Example = createExample(TestedComponent, { ...reducerStatesExample.secretSelection, recovery_document: { - provider_url: 'http://anastasis.url/', + provider_url: 'https://kudos.demo.anastasis.lu/', secret_name: 'secretName', version: 1, }, diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx index 903f57868..8aa5ed2f7 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -1,19 +1,17 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; +import { NumberInput } from "../../components/fields/NumberInput"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function SecretSelectionScreen(): VNode { const [selectingVersion, setSelectingVersion] = useState<boolean>(false); - const [otherProvider, setOtherProvider] = useState<string>(""); const reducer = useAnastasisContext() - const currentVersion = reducer?.currentReducerState + const currentVersion = (reducer?.currentReducerState && ("recovery_document" in reducer.currentReducerState) - && reducer.currentReducerState.recovery_document?.version; - - const [otherVersion, setOtherVersion] = useState<number>(currentVersion || 0); + && reducer.currentReducerState.recovery_document?.version) || 0; if (!reducer) { return <div>no reducer in context</div> @@ -22,9 +20,9 @@ export function SecretSelectionScreen(): VNode { return <div>invalid state</div> } - function selectVersion(p: string, n: number): void { - if (!reducer) return; - reducer.runTransaction(async (tx) => { + async function doSelectVersion(p: string, n: number): Promise<void> { + if (!reducer) return Promise.resolve(); + return reducer.runTransaction(async (tx) => { await tx.transition("change_version", { version: n, provider_url: p, @@ -33,55 +31,136 @@ export function SecretSelectionScreen(): VNode { }); } + const providerList = Object.keys(reducer.currentReducerState.authentication_providers ?? {}) const recoveryDocument = reducer.currentReducerState.recovery_document + if (!recoveryDocument) { - return ( - <AnastasisClientFrame hideNav title="Recovery: Problem"> - <p>No recovery document found</p> - </AnastasisClientFrame> - ) + return <ChooseAnotherProviderScreen + providers={providerList} selected="" + onChange={(newProv) => doSelectVersion(newProv, 0)} + /> } + if (selectingVersion) { - return ( - <AnastasisClientFrame hideNav title="Recovery: Select secret"> - <p>Select a different version of the secret</p> - <select onChange={(e) => setOtherProvider((e.target as any).value)}> - {Object.keys(reducer.currentReducerState.authentication_providers ?? {}).map( - (x, i) => ( - <option key={i} selected={x === recoveryDocument.provider_url} value={x}> - {x} - </option> - ) - )} - </select> - <div> - <input - value={otherVersion} - onChange={(e) => setOtherVersion(Number((e.target as HTMLInputElement).value))} - type="number" /> - <button onClick={() => selectVersion(otherProvider, otherVersion)}> - Use this version - </button> + return <SelectOtherVersionProviderScreen providers={providerList} + provider={recoveryDocument.provider_url} version={recoveryDocument.version} + onCancel={() => setSelectingVersion(false)} + onConfirm={doSelectVersion} + /> + } + + return ( + <AnastasisClientFrame title="Recovery: Select secret"> + <div class="columns"> + <div class="column"> + <div class="box" style={{ border: '2px solid green' }}> + <h1 class="subtitle">{recoveryDocument.provider_url}</h1> + <div class="block"> + {currentVersion === 0 ? <p> + Set to recover the latest version + </p> : <p> + Set to recover the version number {currentVersion} + </p>} + </div> + <div class="buttons is-right"> + <button class="button" onClick={(e) => setSelectingVersion(true)}>Change secret's version</button> + </div> + </div> </div> - <div> - <button onClick={() => selectVersion(otherProvider, 0)}> - Use latest version - </button> + <div class="column"> + <p>Secret found, you can select another version or continue to the challenges solving</p> </div> - <div> - <button onClick={() => setSelectingVersion(false)}>Cancel</button> + </div> + </AnastasisClientFrame> + ); +} + + +function ChooseAnotherProviderScreen({ providers, selected, onChange }: { selected: string; providers: string[]; onChange: (prov: string) => void }): VNode { + return ( + <AnastasisClientFrame hideNext="Recovery document not found" title="Recovery: Problem"> + <p>No recovery document found, try with another provider</p> + <div class="field"> + <label class="label">Provider</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select onChange={(e) => onChange(e.currentTarget.value)} value={selected}> + <option key="none" disabled selected value=""> Choose a provider </option> + {providers.map(prov => ( + <option key={prov} value={prov}> + {prov} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> </div> - </AnastasisClientFrame> - ); - } + </div> + </AnastasisClientFrame> + ); +} + +function SelectOtherVersionProviderScreen({ providers, provider, version, onConfirm, onCancel }: { onCancel: () => void; provider: string; version: number; providers: string[]; onConfirm: (prov: string, v: number) => Promise<void>; }): VNode { + const [otherProvider, setOtherProvider] = useState<string>(provider); + const [otherVersion, setOtherVersion] = useState(`${version}`); + return ( - <AnastasisClientFrame title="Recovery: Select secret"> - <p>Provider: {recoveryDocument.provider_url}</p> - <p>Secret version: {recoveryDocument.version}</p> - <p>Secret name: {recoveryDocument.secret_name}</p> - <button onClick={() => setSelectingVersion(true)}> - Select different secret - </button> + <AnastasisClientFrame hideNav title="Recovery: Select secret"> + <div class="columns"> + <div class="column"> + <div class="box"> + <h1 class="subtitle">Provider {otherProvider}</h1> + <div class="block"> + {version === 0 ? <p> + Set to recover the latest version + </p> : <p> + Set to recover the version number {version} + </p>} + <p>Specify other version below or use the latest</p> + </div> + + <div class="field"> + <label class="label">Provider</label> + <div class="control is-expanded has-icons-left"> + <div class="select is-fullwidth"> + <select onChange={(e) => setOtherProvider(e.currentTarget.value)} value={otherProvider}> + <option key="none" disabled selected value=""> Choose a provider </option> + {providers.map(prov => ( + <option key={prov} value={prov}> + {prov} + </option> + ))} + </select> + <div class="icon is-small is-left"> + <i class="mdi mdi-earth" /> + </div> + </div> + </div> + </div> + <div class="container"> + <NumberInput + label="Version" + placeholder="version number to recover" + grabFocus + bind={[otherVersion, setOtherVersion]} /> + </div> + </div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={onCancel}>Cancel</button> + <div class="buttons"> + <AsyncButton class="button" onClick={() => onConfirm(otherProvider, 0)}>Use latest</AsyncButton> + <AsyncButton class="button is-info" onClick={() => onConfirm(otherProvider, parseInt(otherVersion, 10))}>Confirm</AsyncButton> + </div> + </div> + </div> + <div class="column"> + . + </div> + </div> + </AnastasisClientFrame> ); + } diff --git a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx deleted file mode 100644 index 2c27895c2..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveEmailEntry.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveEmailEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) reducer.transition("solve_challenge", { - answer, - }) - }; - return ( - <AnastasisClientFrame - title="Recovery: Solve challenge" - onNext={() => next()} - > - <p>Feedback: {JSON.stringify(feedback)}</p> - <p>{challenge.instructions}</p> - <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx b/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx deleted file mode 100644 index 1a824acb8..000000000 --- a/packages/anastasis-webui/src/pages/home/SolvePostEntry.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolvePostEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) reducer.transition("solve_challenge", { answer }) - }; - return ( - <AnastasisClientFrame - title="Recovery: Solve challenge" - onNext={() => next()} - > - <p>Feedback: {JSON.stringify(feedback)}</p> - <p>{challenge.instructions}</p> - <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx deleted file mode 100644 index 72dadbe89..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveQuestionEntry.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveQuestionEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) reducer.transition("solve_challenge", { answer }) - }; - return ( - <AnastasisClientFrame - title="Recovery: Solve challenge" - onNext={() => next()} - > - <p>Feedback: {JSON.stringify(feedback)}</p> - <p>Question: {challenge.instructions}</p> - <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx index 69af9be42..cb6561b3f 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx @@ -26,8 +26,11 @@ import { SolveScreen as TestedComponent } from './SolveScreen'; export default { - title: 'Pages/SolveScreen', + title: 'Pages/recovery/SolveScreen', component: TestedComponent, + args: { + order: 6, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, @@ -41,7 +44,7 @@ export const NotSupportedChallenge = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'does P equals NP?', type: 'chall-type', uuid: 'ASDASDSAD!1' }], @@ -55,7 +58,7 @@ export const MismatchedChallengeId = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'does P equals NP?', type: 'chall-type', uuid: 'ASDASDSAD!1' }], @@ -69,7 +72,7 @@ export const SmsChallenge = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'SMS to 555-5555', type: 'sms', uuid: 'ASDASDSAD!1' }], @@ -83,7 +86,7 @@ export const QuestionChallenge = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'does P equals NP?', type: 'question', uuid: 'ASDASDSAD!1' }], @@ -97,7 +100,7 @@ export const EmailChallenge = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'Email to sebasjm@some-domain.com', type: 'email', uuid: 'ASDASDSAD!1' }], @@ -111,7 +114,7 @@ export const PostChallenge = createExample(TestedComponent, { recovery_information: { challenges: [{ cost: 'USD:1', - instructions: 'follow htis instructions', + instructions: 'Letter to address in postal code ABC123', type: 'post', uuid: 'ASDASDSAD!1' }], diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx index 05ae50b48..bc1a88db3 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -1,30 +1,93 @@ -import { h, VNode } from "preact"; -import { ChallengeFeedback, ChallengeInfo } from "../../../../anastasis-core/lib"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AnastasisClientFrame } from "."; +import { + ChallengeFeedback, + ChallengeFeedbackStatus, + ChallengeInfo, +} from "../../../../anastasis-core/lib"; +import { AsyncButton } from "../../components/AsyncButton"; +import { TextInput } from "../../components/fields/TextInput"; import { useAnastasisContext } from "../../context/anastasis"; -import { SolveEmailEntry } from "./SolveEmailEntry"; -import { SolvePostEntry } from "./SolvePostEntry"; -import { SolveQuestionEntry } from "./SolveQuestionEntry"; -import { SolveSmsEntry } from "./SolveSmsEntry"; -import { SolveUnsupportedEntry } from "./SolveUnsupportedEntry"; + +function SolveOverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { + const { feedback } = props; + if (!feedback) { + return null; + } + switch (feedback.state) { + case ChallengeFeedbackStatus.Message: + return ( + <div> + <p>{feedback.message}</p> + </div> + ); + case ChallengeFeedbackStatus.Pending: + case ChallengeFeedbackStatus.AuthIban: + return null; + case ChallengeFeedbackStatus.RateLimitExceeded: + return <div>Rate limit exceeded.</div>; + case ChallengeFeedbackStatus.Redirect: + return <div>Redirect (FIXME: not supported)</div>; + case ChallengeFeedbackStatus.Unsupported: + return <div>Challenge not supported by client.</div>; + case ChallengeFeedbackStatus.TruthUnknown: + return <div>Truth unknown</div>; + default: + return ( + <div> + <pre>{JSON.stringify(feedback)}</pre> + </div> + ); + } +} export function SolveScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); + const [answer, setAnswer] = useState(""); if (!reducer) { - return <div>no reducer in context</div> + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); } - if (!reducer.currentReducerState || reducer.currentReducerState.recovery_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.recovery_state === undefined + ) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + </AnastasisClientFrame> + ); } if (!reducer.currentReducerState.recovery_information) { - return <div>no recovery information found</div> + return ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); } if (!reducer.currentReducerState.selected_challenge_uuid) { - return <div>no selected uuid</div> + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>invalid state</div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={() => reducer.back()}>Back</button> + </div> + </AnastasisClientFrame> + ); } + const chArr = reducer.currentReducerState.recovery_information.challenges; - const challengeFeedback = reducer.currentReducerState.challenge_feedback ?? {}; + const challengeFeedback = + reducer.currentReducerState.challenge_feedback ?? {}; const selectedUuid = reducer.currentReducerState.selected_challenge_uuid; const challenges: { [uuid: string]: ChallengeInfo; @@ -39,16 +102,137 @@ export function SolveScreen(): VNode { email: SolveEmailEntry, post: SolvePostEntry, }; - const SolveDialog = dialogMap[selectedChallenge?.type] ?? SolveUnsupportedEntry; + const SolveDialog = + selectedChallenge === undefined + ? SolveUndefinedEntry + : dialogMap[selectedChallenge.type] ?? SolveUnsupportedEntry; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + return ( - <SolveDialog - challenge={selectedChallenge} - feedback={challengeFeedback[selectedUuid]} /> + <AnastasisClientFrame hideNav title="Recovery: Solve challenge"> + <SolveOverviewFeedbackDisplay + feedback={challengeFeedback[selectedUuid]} + /> + <SolveDialog + id={selectedUuid} + answer={answer} + setAnswer={setAnswer} + challenge={selectedChallenge} + feedback={challengeFeedback[selectedUuid]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + </div> + </AnastasisClientFrame> ); } export interface SolveEntryProps { + id: string; challenge: ChallengeInfo; feedback?: ChallengeFeedback; + answer: string; + setAnswer: (s: string) => void; } +function SolveSmsEntry({ + challenge, + answer, + setAnswer, +}: SolveEntryProps): VNode { + return ( + <Fragment> + <p> + An sms has been sent to "<b>{challenge.instructions}</b>". Type the code + below + </p> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </Fragment> + ); +} +function SolveQuestionEntry({ + challenge, + answer, + setAnswer, +}: SolveEntryProps): VNode { + return ( + <Fragment> + <p>Type the answer to the following question:</p> + <pre>{challenge.instructions}</pre> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </Fragment> + ); +} + +function SolvePostEntry({ + challenge, + answer, + setAnswer, +}: SolveEntryProps): VNode { + return ( + <Fragment> + <p> + instruction for post type challenge "<b>{challenge.instructions}</b>" + </p> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </Fragment> + ); +} + +function SolveEmailEntry({ + challenge, + answer, + setAnswer, +}: SolveEntryProps): VNode { + return ( + <Fragment> + <p> + An email has been sent to "<b>{challenge.instructions}</b>". Type the + code below + </p> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + </Fragment> + ); +} + +function SolveUnsupportedEntry(props: SolveEntryProps): VNode { + return ( + <Fragment> + <p> + The challenge selected is not supported for this UI. Please update this + version or try using another policy. + </p> + <p> + <b>Challenge type:</b> {props.challenge.type} + </p> + </Fragment> + ); +} +function SolveUndefinedEntry(props: SolveEntryProps): VNode { + return ( + <Fragment> + <p> + There is no challenge information for id <b>"{props.id}"</b>. Try + resetting the recovery session. + </p> + </Fragment> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx deleted file mode 100644 index 163e0d1f3..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveSmsEntry.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { h, VNode } from "preact"; -import { useState } from "preact/hooks"; -import { useAnastasisContext } from "../../context/anastasis"; -import { AnastasisClientFrame, LabeledInput } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveSmsEntry({ challenge, feedback }: SolveEntryProps): VNode { - const [answer, setAnswer] = useState(""); - const reducer = useAnastasisContext() - const next = (): void => { - if (reducer) reducer.transition("solve_challenge", { - answer, - }) - }; - return ( - <AnastasisClientFrame - title="Recovery: Solve challenge" - onNext={() => next()} - > - <p>Feedback: {JSON.stringify(feedback)}</p> - <p>{challenge.instructions}</p> - <LabeledInput label="Answer" grabFocus bind={[answer, setAnswer]} /> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx b/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx deleted file mode 100644 index 7f538d249..000000000 --- a/packages/anastasis-webui/src/pages/home/SolveUnsupportedEntry.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { h, VNode } from "preact"; -import { AnastasisClientFrame } from "./index"; -import { SolveEntryProps } from "./SolveScreen"; - -export function SolveUnsupportedEntry(props: SolveEntryProps): VNode { - return ( - <AnastasisClientFrame hideNext title="Recovery: Solve challenge"> - <p>{JSON.stringify(props.challenge)}</p> - <p>Challenge not supported.</p> - </AnastasisClientFrame> - ); -} diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx index ad84cd8f2..657a2dd74 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx @@ -26,6 +26,9 @@ import { StartScreen as TestedComponent } from './StartScreen'; export default { title: 'Pages/StartScreen', component: TestedComponent, + args: { + order: 1, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx index 6625ec5b8..d53df4cae 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -10,24 +10,29 @@ export function StartScreen(): VNode { } return ( <AnastasisClientFrame hideNav title="Home"> - <div> - <section class="section is-main-section"> - <div class="columns"> - <div class="column" /> - <div class="column is-four-fifths"> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> - <div class="buttons is-right"> - <button class="button is-success" autoFocus onClick={() => reducer.startBackup()}> - Backup - </button> + <div class="buttons"> + <button class="button is-success" autoFocus onClick={() => reducer.startBackup()}> + <div class="icon"><i class="mdi mdi-arrow-up" /></div> + <span>Backup a secret</span> + </button> - <button class="button is-info" onClick={() => reducer.startRecover()}>Recover</button> - </div> + <button class="button is-info" onClick={() => reducer.startRecover()}> + <div class="icon"><i class="mdi mdi-arrow-down" /></div> + <span>Recover a secret</span> + </button> - </div> - <div class="column" /> + {/* <button class="button"> + <div class="icon"><i class="mdi mdi-file" /></div> + <span>Restore a session</span> + </button> */} </div> - </section> + + </div> + <div class="column" /> </div> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx index e2f3d521e..7568ccd69 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx @@ -25,8 +25,11 @@ import { TruthsPayingScreen as TestedComponent } from './TruthsPayingScreen'; export default { - title: 'Pages/TruthsPayingScreen', + title: 'Pages/backup/__TruthsPayingScreen', component: TestedComponent, + args: { + order: 10, + }, argTypes: { onUpdate: { action: 'onUpdate' }, onBack: { action: 'onBack' }, diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx index 319f590a0..0b32e0db5 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -13,8 +13,8 @@ export function TruthsPayingScreen(): VNode { const payments = reducer.currentReducerState.payments ?? []; return ( <AnastasisClientFrame - hideNext - title="Backup: Authentication Storage Payments" + hideNext={"FIXME"} + title="Backup: Truths Paying" > <p> Some of the providers require a payment to store the encrypted diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx new file mode 100644 index 000000000..e178a4955 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/email', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'email' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Email to sebasjm@email.com ', + remove: () => null + }] +}); + +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Email to sebasjm@email.com', + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: 'Email to someone@sebasjm.com', + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx new file mode 100644 index 000000000..1a6be1b61 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; +import { TextInput } from "../../../components/fields/TextInput"; +import { EmailInput } from "../../../components/fields/EmailInput"; + +const EMAIL_PATTERN = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + +export function AuthMethodEmailSetup({ cancel, addAuthMethod, configured }: AuthMethodSetupProps): VNode { + const [email, setEmail] = useState(""); + const addEmailAuth = (): void => addAuthMethod({ + authentication_method: { + type: "email", + instructions: `Email to ${email}`, + challenge: encodeCrock(stringToBytes(email)), + }, + }); + const emailError = !EMAIL_PATTERN.test(email) ? 'Email address is not valid' : undefined + const errors = !email ? 'Add your email' : emailError + + return ( + <AnastasisClientFrame hideNav title="Add email authentication"> + <p> + For email authentication, you need to provide an email address. When + recovering your secret, you will need to enter the code you receive by + email. + </p> + <div> + <EmailInput + label="Email address" + error={emailError} + placeholder="email@domain.com" + bind={[email, setEmail]} /> + </div> + {configured.length > 0 && <section class="section"> + <div class="block"> + Your emails: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginBottom: 'auto', marginTop: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove} >Delete</button></div> + </div> + })} + </div></section>} + <div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addEmailAuth}>Add</button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx new file mode 100644 index 000000000..71f618646 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx @@ -0,0 +1,65 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/IBAN', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'iban' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Wire transfer from QWEASD123123 with holder Sebastian', + remove: () => null + }] +}); +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Wire transfer from QWEASD123123 with holder Javier', + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: 'Wire transfer from QWEASD123123 with holder Sebastian', + remove: () => null + }] +},); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx new file mode 100644 index 000000000..c9edbfa07 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + canonicalJson, + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { TextInput } from "../../../components/fields/TextInput"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; + +export function AuthMethodIbanSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { + const [name, setName] = useState(""); + const [account, setAccount] = useState(""); + const addIbanAuth = (): void => addAuthMethod({ + authentication_method: { + type: "iban", + instructions: `Wire transfer from ${account} with holder ${name}`, + challenge: encodeCrock(stringToBytes(canonicalJson({ + name, account + }))), + }, + }); + const errors = !name ? 'Add an account name' : ( + !account ? 'Add an account IBAN number' : undefined + ) + return ( + <AnastasisClientFrame hideNav title="Add bank transfer authentication"> + <p> + For bank transfer authentication, you need to provide a bank + account (account holder name and IBAN). When recovering your + secret, you will be asked to pay the recovery fee via bank + transfer from the account you provided here. + </p> + <div> + <TextInput + label="Bank account holder name" + grabFocus + placeholder="John Smith" + bind={[name, setName]} /> + <TextInput + label="IBAN" + placeholder="DE91100000000123456789" + bind={[account, setAccount]} /> + </div> + {configured.length > 0 && <section class="section"> + <div class="block"> + Your bank accounts: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginBottom: 'auto', marginTop: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove} >Delete</button></div> + </div> + })} + </div></section>} + <div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addIbanAuth}>Add</button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx new file mode 100644 index 000000000..0f1c17495 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/Post', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'post' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Letter to address in postal code QWE456', + remove: () => null + }] +}); + +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Letter to address in postal code QWE456', + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: 'Letter to address in postal code ABC123', + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx new file mode 100644 index 000000000..bfeaaa832 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx @@ -0,0 +1,102 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + canonicalJson, encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { TextInput } from "../../../components/fields/TextInput"; +import { AnastasisClientFrame } from ".."; + +export function AuthMethodPostSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { + const [fullName, setFullName] = useState(""); + const [street, setStreet] = useState(""); + const [city, setCity] = useState(""); + const [postcode, setPostcode] = useState(""); + const [country, setCountry] = useState(""); + + const addPostAuth = () => { + const challengeJson = { + full_name: fullName, + street, + city, + postcode, + country, + }; + addAuthMethod({ + authentication_method: { + type: "post", + instructions: `Letter to address in postal code ${postcode}`, + challenge: encodeCrock(stringToBytes(canonicalJson(challengeJson))), + }, + }); + }; + + const errors = !fullName ? 'The full name is missing' : ( + !street ? 'The street is missing' : ( + !city ? 'The city is missing' : ( + !postcode ? 'The postcode is missing' : ( + !country ? 'The country is missing' : undefined + ) + ) + ) + ) + return ( + <AnastasisClientFrame hideNav title="Add postal authentication"> + <p> + For postal letter authentication, you need to provide a postal + address. When recovering your secret, you will be asked to enter a + code that you will receive in a letter to that address. + </p> + <div> + <TextInput + grabFocus + label="Full Name" + bind={[fullName, setFullName]} + /> + </div> + <div> + <TextInput + label="Street" + bind={[street, setStreet]} + /> + </div> + <div> + <TextInput + label="City" bind={[city, setCity]} + /> + </div> + <div> + <TextInput + label="Postal Code" bind={[postcode, setPostcode]} + /> + </div> + <div> + <TextInput + label="Country" + bind={[country, setCountry]} + /> + </div> + + {configured.length > 0 && <section class="section"> + <div class="block"> + Your postal code: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginBottom: 'auto', marginTop: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove} >Delete</button></div> + </div> + })} + </div> + </section>} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addPostAuth}>Add</button> + </span> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx new file mode 100644 index 000000000..3ba4a84ca --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/Question', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'question' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Is integer factorization polynomial? (non-quantum computer)', + remove: () => null + }] +}); + +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Does P equal NP?', + remove: () => null + },{ + challenge: 'asd', + type, + instructions: 'Are continuous groups automatically differential groups?', + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx new file mode 100644 index 000000000..04fa00d59 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx @@ -0,0 +1,71 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; +import { TextInput } from "../../../components/fields/TextInput"; + +export function AuthMethodQuestionSetup({ cancel, addAuthMethod, configured }: AuthMethodSetupProps): VNode { + const [questionText, setQuestionText] = useState(""); + const [answerText, setAnswerText] = useState(""); + const addQuestionAuth = (): void => addAuthMethod({ + authentication_method: { + type: "question", + instructions: questionText, + challenge: encodeCrock(stringToBytes(answerText)), + }, + }); + + const errors = !questionText ? "Add your security question" : ( + !answerText ? 'Add the answer to your question' : undefined + ) + return ( + <AnastasisClientFrame hideNav title="Add Security Question"> + <div> + <p> + For2 security question authentication, you need to provide a question + and its answer. When recovering your secret, you will be shown the + question and you will need to type the answer exactly as you typed it + here. + </p> + <div> + <TextInput + label="Security question" + grabFocus + placeholder="Your question" + bind={[questionText, setQuestionText]} /> + </div> + <div> + <TextInput + label="Answer" + placeholder="Your answer" + bind={[answerText, setAnswerText]} + /> + </div> + + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addQuestionAuth}>Add</button> + </span> + </div> + + {configured.length > 0 && <section class="section"> + <div class="block"> + Your security questions: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginBottom: 'auto', marginTop: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove} >Delete</button></div> + </div> + })} + </div></section>} + </div> + </AnastasisClientFrame > + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx new file mode 100644 index 000000000..ae8297ef7 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/Sms', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'sms' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'SMS to +11-1234-2345', + remove: () => null + }] +}); + +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'SMS to +11-1234-2345', + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: 'SMS to +11-5555-2345', + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx new file mode 100644 index 000000000..9e85af2b2 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx @@ -0,0 +1,63 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { NumberInput } from "../../../components/fields/NumberInput"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; + +export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { + const [mobileNumber, setMobileNumber] = useState(""); + const addSmsAuth = (): void => { + addAuthMethod({ + authentication_method: { + type: "sms", + instructions: `SMS to ${mobileNumber}`, + challenge: encodeCrock(stringToBytes(mobileNumber)), + }, + }); + }; + const inputRef = useRef<HTMLInputElement>(null); + useLayoutEffect(() => { + inputRef.current?.focus(); + }, []); + const errors = !mobileNumber ? 'Add a mobile number' : undefined + return ( + <AnastasisClientFrame hideNav title="Add SMS authentication"> + <div> + <p> + For SMS authentication, you need to provide a mobile number. When + recovering your secret, you will be asked to enter the code you + receive via SMS. + </p> + <div class="container"> + <NumberInput + label="Mobile number" + placeholder="Your mobile number" + grabFocus + bind={[mobileNumber, setMobileNumber]} /> + </div> + {configured.length > 0 && <section class="section"> + <div class="block"> + Your mobile numbers: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginTop: 'auto', marginBottom: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove}>Delete</button></div> + </div> + })} + </div></section>} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addSmsAuth}>Add</button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx new file mode 100644 index 000000000..4e46b600e --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx @@ -0,0 +1,64 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + + +export default { + title: 'Pages/backup/authMethods/TOTP', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'totp' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Enter 8 digits code for "Anastasis"', + remove: () => null + }] +}); +export const WithMoreExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: 'Enter 8 digits code for "Anastasis1"', + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: 'Enter 8 digits code for "Anastasis2"', + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx new file mode 100644 index 000000000..fd0bd0224 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -0,0 +1,81 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useMemo, useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; +import { TextInput } from "../../../components/fields/TextInput"; +import { QR } from "../../../components/QR"; +import { base32enc, computeTOTPandCheck } from "./totp"; + +export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { + const [name, setName] = useState("anastasis"); + const [test, setTest] = useState(""); + const digits = 8 + const secretKey = useMemo(() => { + const array = new Uint8Array(32) + return window.crypto.getRandomValues(array) + }, []) + const secret32 = base32enc(secretKey); + const totpURL = `otpauth://totp/${name}?digits=${digits}&secret=${secret32}` + + const addTotpAuth = (): void => addAuthMethod({ + authentication_method: { + type: "totp", + instructions: `Enter ${digits} digits code for "${name}"`, + challenge: encodeCrock(stringToBytes(totpURL)), + }, + }); + + const testCodeMatches = computeTOTPandCheck(secretKey, 8, parseInt(test, 10)); + + const errors = !name ? 'The TOTP name is missing' : ( + !testCodeMatches ? 'The test code doesnt match' : undefined + ); + return ( + <AnastasisClientFrame hideNav title="Add TOTP authentication"> + <p> + For Time-based One-Time Password (TOTP) authentication, you need to set + a name for the TOTP secret. Then, you must scan the generated QR code + with your TOTP App to import the TOTP secret into your TOTP App. + </p> + <div class="block"> + <TextInput + label="TOTP Name" + grabFocus + bind={[name, setName]} /> + </div> + <div style={{ height: 300 }}> + <QR text={totpURL} /> + </div> + <p> + After scanning the code with your TOTP App, test it in the input below. + </p> + <TextInput + label="Test code" + bind={[test, setTest]} /> + {configured.length > 0 && <section class="section"> + <div class="block"> + Your TOTP numbers: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <p style={{ marginTop: 'auto', marginBottom: 'auto' }}>{c.instructions}</p> + <div><button class="button is-danger" onClick={c.remove}>Delete</button></div> + </div> + })} + </div></section>} + <div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <span data-tooltip={errors}> + <button class="button is-info" disabled={errors !== undefined} onClick={addTotpAuth}>Add</button> + </span> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx new file mode 100644 index 000000000..3c4c7bf39 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable @typescript-eslint/camelcase */ +/* + This file is part of GNU Taler + (C) 2021 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** +* +* @author Sebastian Javier Marchano (sebasjm) +*/ + +import { createExample, reducerStatesExample } from '../../../utils'; +import { authMethods as TestedComponent, KnownAuthMethods } from './index'; +import logoImage from '../../../assets/logo.jpeg' + +export default { + title: 'Pages/backup/authMethods/Video', + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: 'onUpdate' }, + onBack: { action: 'onBack' }, + }, +}; + +const type: KnownAuthMethods = 'video' + +export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [] +}); + +export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: logoImage, + remove: () => null + }] +}); + +export const WithMoreExamples = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { + configured: [{ + challenge: 'qwe', + type, + instructions: logoImage, + remove: () => null + },{ + challenge: 'qwe', + type, + instructions: logoImage, + remove: () => null + }] +}); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx new file mode 100644 index 000000000..8be999b3f --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import { + encodeCrock, + stringToBytes +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { ImageInput } from "../../../components/fields/ImageInput"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AnastasisClientFrame } from "../index"; + +export function AuthMethodVideoSetup({cancel, addAuthMethod, configured}: AuthMethodSetupProps): VNode { + const [image, setImage] = useState(""); + const addVideoAuth = (): void => { + addAuthMethod({ + authentication_method: { + type: "video", + instructions: 'Join a video call', + challenge: encodeCrock(stringToBytes(image)), + }, + }) + }; + return ( + <AnastasisClientFrame hideNav title="Add video authentication"> + <p> + For video identification, you need to provide a passport-style + photograph. When recovering your secret, you will be asked to join a + video call. During that call, a human will use the photograph to + verify your identity. + </p> + <div style={{textAlign:'center'}}> + <ImageInput + label="Choose photograph" + grabFocus + bind={[image, setImage]} /> + </div> + {configured.length > 0 && <section class="section"> + <div class="block"> + Your photographs: + </div><div class="block"> + {configured.map((c, i) => { + return <div key={i} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <img style={{ marginTop: 'auto', marginBottom: 'auto', width: 100, height:100, border: 'solid 1px black' }} src={c.instructions} /> + <div style={{marginTop: 'auto', marginBottom: 'auto'}}><button class="button is-danger" onClick={c.remove}>Delete</button></div> + </div> + })} + </div></section>} + <div> + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={cancel}>Cancel</button> + <button class="button is-info" onClick={addVideoAuth}>Add</button> + </div> + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx new file mode 100644 index 000000000..7b0cce883 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx @@ -0,0 +1,69 @@ +import { h, VNode } from "preact"; +import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; + +import { AuthMethodEmailSetup as EmailScreen } from "./AuthMethodEmailSetup"; +import { AuthMethodIbanSetup as IbanScreen } from "./AuthMethodIbanSetup"; +import { AuthMethodPostSetup as PostalScreen } from "./AuthMethodPostSetup"; +import { AuthMethodQuestionSetup as QuestionScreen } from "./AuthMethodQuestionSetup"; +import { AuthMethodSmsSetup as SmsScreen } from "./AuthMethodSmsSetup"; +import { AuthMethodTotpSetup as TotpScreen } from "./AuthMethodTotpSetup"; +import { AuthMethodVideoSetup as VideScreen } from "./AuthMethodVideoSetup"; +import postalIcon from '../../../assets/icons/auth_method/postal.svg'; +import questionIcon from '../../../assets/icons/auth_method/question.svg'; +import smsIcon from '../../../assets/icons/auth_method/sms.svg'; +import videoIcon from '../../../assets/icons/auth_method/video.svg'; + +interface AuthMethodConfiguration { + icon: VNode; + label: string; + screen: (props: AuthMethodSetupProps) => VNode; + skip?: boolean; +} +export type KnownAuthMethods = "sms" | "email" | "post" | "question" | "video" | "totp" | "iban"; + +type KnowMethodConfig = { + [name in KnownAuthMethods]: AuthMethodConfiguration; +}; + +export const authMethods: KnowMethodConfig = { + question: { + icon: <img src={questionIcon} />, + label: "Question", + screen: QuestionScreen + }, + sms: { + icon: <img src={smsIcon} />, + label: "SMS", + screen: SmsScreen + }, + email: { + icon: <i class="mdi mdi-email" />, + label: "Email", + screen: EmailScreen + + }, + iban: { + icon: <i class="mdi mdi-bank" />, + label: "IBAN", + screen: IbanScreen + + }, + post: { + icon: <img src={postalIcon} />, + label: "Physical mail", + screen: PostalScreen + + }, + totp: { + icon: <i class="mdi mdi-devices" />, + label: "TOTP", + screen: TotpScreen + + }, + video: { + icon: <img src={videoIcon} />, + label: "Video", + screen: VideScreen, + skip: true, + } +}
\ No newline at end of file diff --git a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts new file mode 100644 index 000000000..0bc3feaf8 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts @@ -0,0 +1,56 @@ +/* eslint-disable @typescript-eslint/camelcase */ +import jssha from 'jssha' + +const SEARCH_RANGE = 16 +const timeStep = 30 + +export function computeTOTPandCheck(secretKey: Uint8Array, digits: number, code: number): boolean { + const now = new Date().getTime() + const epoch = Math.floor(Math.round(now / 1000.0) / timeStep); + + for (let ms = -SEARCH_RANGE; ms < SEARCH_RANGE; ms++) { + const movingFactor = (epoch + ms).toString(16).padStart(16, "0"); + + const hmacSha = new jssha('SHA-1', 'HEX', { hmacKey: { value: secretKey, format: 'UINT8ARRAY' } }); + hmacSha.update(movingFactor); + const hmac_text = hmacSha.getHMAC('UINT8ARRAY'); + + const offset = (hmac_text[hmac_text.length - 1] & 0xf) + + const otp = (( + (hmac_text[offset + 0] << 24) + + (hmac_text[offset + 1] << 16) + + (hmac_text[offset + 2] << 8) + + (hmac_text[offset + 3]) + ) & 0x7fffffff) % Math.pow(10, digits) + + if (otp == code) return true + } + return false +} + +const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split('') +export function base32enc(buffer: Uint8Array): string { + let rpos = 0 + let bits = 0 + let vbit = 0 + + let result = "" + while ((rpos < buffer.length) || (vbit > 0)) { + if ((rpos < buffer.length) && (vbit < 5)) { + bits = (bits << 8) | buffer[rpos++]; + vbit += 8; + } + if (vbit < 5) { + bits <<= (5 - vbit); + vbit = 5; + } + result += encTable__[(bits >> (vbit - 5)) & 31]; + vbit -= 5; + } + return result +} + +// const array = new Uint8Array(256) +// const secretKey = window.crypto.getRandomValues(array) +// console.log(base32enc(secretKey)) diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx index 4cec47ec8..07bc7c604 100644 --- a/packages/anastasis-webui/src/pages/home/index.tsx +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -11,11 +11,11 @@ import { VNode } from "preact"; import { - useErrorBoundary, - useLayoutEffect, - useRef + useErrorBoundary } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; import { Menu } from "../../components/menu"; +import { Notifications } from "../../components/Notifications"; import { AnastasisProvider, useAnastasisContext } from "../../context/anastasis"; import { AnastasisReducerApi, @@ -25,8 +25,8 @@ import { AttributeEntryScreen } from "./AttributeEntryScreen"; import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; import { BackupFinishedScreen } from "./BackupFinishedScreen"; import { ChallengeOverviewScreen } from "./ChallengeOverviewScreen"; +import { ChallengePayingScreen } from "./ChallengePayingScreen"; import { ContinentSelectionScreen } from "./ContinentSelectionScreen"; -import { CountrySelectionScreen } from "./CountrySelectionScreen"; import { PoliciesPayingScreen } from "./PoliciesPayingScreen"; import { RecoveryFinishedScreen } from "./RecoveryFinishedScreen"; import { ReviewPoliciesScreen } from "./ReviewPoliciesScreen"; @@ -61,7 +61,7 @@ interface AnastasisClientFrameProps { /** * Hide only the "next" button. */ - hideNext?: boolean; + hideNext?: string; } function ErrorBoundary(props: { @@ -96,11 +96,11 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { if (!reducer) { return <p>Fatal: Reducer must be in context.</p>; } - const next = (): void => { + const next = async (): Promise<void> => { if (props.onNext) { - props.onNext(); + await props.onNext(); } else { - reducer.transition("next", {}); + await reducer.transition("next", {}); } }; const handleKeyPress = ( @@ -112,18 +112,18 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { return ( <Fragment> <Menu title="Anastasis" /> - <div> - <div class="home" onKeyPress={(e) => handleKeyPress(e)}> - <h1>{props.title}</h1> - <ErrorBanner /> + <div class="home" onKeyPress={(e) => handleKeyPress(e)}> + <h1 class="title">{props.title}</h1> + <ErrorBanner /> + <section class="section is-main-section"> {props.children} {!props.hideNav ? ( - <div> - <button onClick={() => reducer.back()}>Back</button> - {!props.hideNext ? <button onClick={next}>Next</button> : null} + <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> + <button class="button" onClick={() => reducer.back()}>Back</button> + <AsyncButton class="button is-info" data-tooltip={props.hideNext} onClick={next} disabled={props.hideNext !== undefined}>Next</AsyncButton> </div> ) : null} - </div> + </section> </div> </Fragment> ); @@ -140,7 +140,7 @@ const AnastasisClient: FunctionalComponent = () => { ); }; -const AnastasisClientImpl: FunctionalComponent = () => { +function AnastasisClientImpl(): VNode { const reducer = useAnastasisContext() if (!reducer) { return <p>Fatal: Reducer must be in context.</p>; @@ -153,18 +153,12 @@ const AnastasisClientImpl: FunctionalComponent = () => { if ( state.backup_state === BackupStates.ContinentSelecting || - state.recovery_state === RecoveryStates.ContinentSelecting - ) { - return ( - <ContinentSelectionScreen /> - ); - } - if ( + state.recovery_state === RecoveryStates.ContinentSelecting || state.backup_state === BackupStates.CountrySelecting || state.recovery_state === RecoveryStates.CountrySelecting ) { return ( - <CountrySelectionScreen /> + <ContinentSelectionScreen /> ); } if ( @@ -222,7 +216,9 @@ const AnastasisClientImpl: FunctionalComponent = () => { <RecoveryFinishedScreen /> ); } - + if (state.recovery_state === RecoveryStates.ChallengePaying) { + return <ChallengePayingScreen />; + } console.log("unknown state", reducer.currentReducerState); return ( <AnastasisClientFrame hideNav title="Bug"> @@ -232,32 +228,6 @@ const AnastasisClientImpl: FunctionalComponent = () => { </div> </AnastasisClientFrame> ); -}; - -interface LabeledInputProps { - label: string; - grabFocus?: boolean; - bind: [string, (x: string) => void]; -} - -export function LabeledInput(props: LabeledInputProps): VNode { - const inputRef = useRef<HTMLInputElement>(null); - useLayoutEffect(() => { - if (props.grabFocus) { - inputRef.current?.focus(); - } - }, [props.grabFocus]); - return ( - <label> - {props.label} - <input - value={props.bind[0]} - onChange={(e) => props.bind[1]((e.target as HTMLInputElement).value)} - ref={inputRef} - style={{ display: "block" }} - /> - </label> - ); } /** @@ -266,13 +236,11 @@ export function LabeledInput(props: LabeledInputProps): VNode { function ErrorBanner(): VNode | null { const reducer = useAnastasisContext(); if (!reducer || !reducer.currentError) return null; - return ( - <div id="error"> - <p>Error: {JSON.stringify(reducer.currentError)}</p> - <button onClick={() => reducer.dismissError()}> - Dismiss Error - </button> - </div> + return (<Notifications removeNotification={reducer.dismissError} notifications={[{ + type: "ERROR", + message: `Error code: ${reducer.currentError.code}`, + description: reducer.currentError.hint + }]} /> ); } diff --git a/packages/anastasis-webui/src/scss/_custom-calendar.scss b/packages/anastasis-webui/src/scss/_custom-calendar.scss index 9ac877ce0..bff68cf79 100644 --- a/packages/anastasis-webui/src/scss/_custom-calendar.scss +++ b/packages/anastasis-webui/src/scss/_custom-calendar.scss @@ -41,6 +41,10 @@ } +.home .datePicker div { + margin-top: 0px; + margin-bottom: 0px; +} .datePicker { text-align: left; background: var(--primary-card-color); diff --git a/packages/anastasis-webui/src/scss/main.scss b/packages/anastasis-webui/src/scss/main.scss index 2e60bf6f9..b5335073f 100644 --- a/packages/anastasis-webui/src/scss/main.scss +++ b/packages/anastasis-webui/src/scss/main.scss @@ -195,13 +195,13 @@ div[data-tooltip]::before { padding: 1em 1em; min-height: 100%; width: 100%; - max-width: 40em; + // max-width: 40em; } -.home div { - margin-top: 0.5em; - margin-bottom: 0.5em; -} +// .home div { +// margin-top: 0.5em; +// margin-bottom: 0.5em; +// } .policy { padding: 0.5em; diff --git a/packages/anastasis-webui/src/utils/index.tsx b/packages/anastasis-webui/src/utils/index.tsx index d1d861469..9c01aa6ba 100644 --- a/packages/anastasis-webui/src/utils/index.tsx +++ b/packages/anastasis-webui/src/utils/index.tsx @@ -8,13 +8,13 @@ export function createExample<Props>(Component: FunctionalComponent<Props>, curr return <AnastasisProvider value={{ currentReducerState, currentError: undefined, - back: () => { null }, - dismissError: () => { null }, + back: async () => { null }, + dismissError: async () => { null }, reset: () => { null }, - runTransaction: () => { null }, + runTransaction: async () => { null }, startBackup: () => { null }, startRecover: () => { null }, - transition: () => { null }, + transition: async () => { null }, }}> <Component {...args} /> </AnastasisProvider> @@ -86,12 +86,60 @@ const base = { { type: "question", usage_fee: "COL:0" - } + }, { + type: "sms", + usage_fee: "COL:0" + }, { + type: "email", + usage_fee: "COL:0" + }, + ], + salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", + storage_limit_in_megabytes: 16, + truth_upload_fee: "COL:0" + }, + "https://kudos.demo.anastasis.lu/": { + http_status: 200, + annual_fee: "COL:0", + business_name: "ana", + currency: "COL", + liability_limit: "COL:10", + methods: [ + { + type: "question", + usage_fee: "COL:0" + }, { + type: "email", + usage_fee: "COL:0" + }, + ], + salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", + storage_limit_in_megabytes: 16, + truth_upload_fee: "COL:0" + }, + "https://anastasis.demo.taler.net/": { + http_status: 200, + annual_fee: "COL:0", + business_name: "ana", + currency: "COL", + liability_limit: "COL:10", + methods: [ + { + type: "question", + usage_fee: "COL:0" + }, { + type: "sms", + usage_fee: "COL:0" + }, { + type: "totp", + usage_fee: "COL:0" + }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, truth_upload_fee: "COL:0" }, + "http://localhost:8087/": { code: 8414, hint: "request to provider failed" @@ -112,49 +160,72 @@ const base = { export const reducerStatesExample = { initial: undefined, - recoverySelectCountry: {...base, + recoverySelectCountry: { + ...base, recovery_state: RecoveryStates.CountrySelecting } as ReducerState, - backupSelectCountry: {...base, - backup_state: BackupStates.CountrySelecting - } as ReducerState, - recoverySelectContinent: {...base, + recoverySelectContinent: { + ...base, recovery_state: RecoveryStates.ContinentSelecting, } as ReducerState, - backupSelectContinent: {...base, - backup_state: BackupStates.ContinentSelecting, - } as ReducerState, - secretSelection: {...base, + secretSelection: { + ...base, recovery_state: RecoveryStates.SecretSelecting, } as ReducerState, - recoveryFinished: {...base, + recoveryFinished: { + ...base, recovery_state: RecoveryStates.RecoveryFinished, } as ReducerState, - challengeSelecting: {...base, + challengeSelecting: { + ...base, recovery_state: RecoveryStates.ChallengeSelecting, } as ReducerState, - challengeSolving: {...base, + challengeSolving: { + ...base, recovery_state: RecoveryStates.ChallengeSolving, } as ReducerState, - secretEdition: {...base, + challengePaying: { + ...base, + recovery_state: RecoveryStates.ChallengePaying, + } as ReducerState, + recoveryAttributeEditing: { + ...base, + recovery_state: RecoveryStates.UserAttributesCollecting + } as ReducerState, + backupSelectCountry: { + ...base, + backup_state: BackupStates.CountrySelecting + } as ReducerState, + backupSelectContinent: { + ...base, + backup_state: BackupStates.ContinentSelecting, + } as ReducerState, + secretEdition: { + ...base, backup_state: BackupStates.SecretEditing, } as ReducerState, - policyReview: {...base, + policyReview: { + ...base, backup_state: BackupStates.PoliciesReviewing, } as ReducerState, - policyPay: {...base, + policyPay: { + ...base, backup_state: BackupStates.PoliciesPaying, } as ReducerState, - backupFinished: {...base, + backupFinished: { + ...base, backup_state: BackupStates.BackupFinished, } as ReducerState, - authEditing: {...base, + authEditing: { + ...base, backup_state: BackupStates.AuthenticationsEditing } as ReducerState, - attributeEditing: {...base, + backupAttributeEditing: { + ...base, backup_state: BackupStates.UserAttributesCollecting } as ReducerState, - truthsPaying: {...base, + truthsPaying: { + ...base, backup_state: BackupStates.TruthsPaying } as ReducerState, diff --git a/packages/taler-util/src/amounts.ts b/packages/taler-util/src/amounts.ts index 5a8c7f06f..41fd14bee 100644 --- a/packages/taler-util/src/amounts.ts +++ b/packages/taler-util/src/amounts.ts @@ -349,7 +349,8 @@ export class Amounts { } } - static mult(a: AmountJson, n: number): Result { + static mult(a: AmountLike, n: number): Result { + a = this.jsonifyAmount(a); if (!Number.isInteger(n)) { throw Error("amount can only be multipied by an integer"); } diff --git a/packages/taler-util/src/clk.ts b/packages/taler-util/src/clk.ts new file mode 100644 index 000000000..d172eed48 --- /dev/null +++ b/packages/taler-util/src/clk.ts @@ -0,0 +1,620 @@ +/* + This file is part of GNU Taler + (C) 2019 GNUnet e.V. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * Imports. + */ +import process from "process"; +import path from "path"; +import readline from "readline"; + +export namespace clk { + class Converter<T> {} + + export const INT = new Converter<number>(); + export const STRING: Converter<string> = new Converter<string>(); + + export interface OptionArgs<T> { + help?: string; + default?: T; + onPresentHandler?: (v: T) => void; + } + + export interface ArgumentArgs<T> { + metavar?: string; + help?: string; + default?: T; + } + + export interface SubcommandArgs { + help?: string; + } + + export interface FlagArgs { + help?: string; + } + + export interface ProgramArgs { + help?: string; + } + + interface ArgumentDef { + name: string; + conv: Converter<any>; + args: ArgumentArgs<any>; + required: boolean; + } + + interface SubcommandDef { + commandGroup: CommandGroup<any, any>; + name: string; + args: SubcommandArgs; + } + + type ActionFn<TG> = (x: TG) => void; + + type SubRecord<S extends keyof any, N extends keyof any, V> = { + [Y in S]: { [X in N]: V }; + }; + + interface OptionDef { + name: string; + flagspec: string[]; + /** + * Converter, only present for options, not for flags. + */ + conv?: Converter<any>; + args: OptionArgs<any>; + isFlag: boolean; + required: boolean; + } + + function splitOpt(opt: string): { key: string; value?: string } { + const idx = opt.indexOf("="); + if (idx == -1) { + return { key: opt }; + } + return { key: opt.substring(0, idx), value: opt.substring(idx + 1) }; + } + + function formatListing(key: string, value?: string): string { + const res = " " + key; + if (!value) { + return res; + } + if (res.length >= 25) { + return res + "\n" + " " + value; + } else { + return res.padEnd(24) + " " + value; + } + } + + export class CommandGroup<GN extends keyof any, TG> { + private shortOptions: { [name: string]: OptionDef } = {}; + private longOptions: { [name: string]: OptionDef } = {}; + private subcommandMap: { [name: string]: SubcommandDef } = {}; + private subcommands: SubcommandDef[] = []; + private options: OptionDef[] = []; + private arguments: ArgumentDef[] = []; + + private myAction?: ActionFn<TG>; + + constructor( + private argKey: string, + private name: string | null, + private scArgs: SubcommandArgs, + ) {} + + action(f: ActionFn<TG>): void { + if (this.myAction) { + throw Error("only one action supported per command"); + } + this.myAction = f; + } + + requiredOption<N extends keyof any, V>( + name: N, + flagspec: string[], + conv: Converter<V>, + args: OptionArgs<V> = {}, + ): CommandGroup<GN, TG & SubRecord<GN, N, V>> { + const def: OptionDef = { + args: args, + conv: conv, + flagspec: flagspec, + isFlag: false, + required: true, + name: name as string, + }; + this.options.push(def); + for (const flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + maybeOption<N extends keyof any, V>( + name: N, + flagspec: string[], + conv: Converter<V>, + args: OptionArgs<V> = {}, + ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> { + const def: OptionDef = { + args: args, + conv: conv, + flagspec: flagspec, + isFlag: false, + required: false, + name: name as string, + }; + this.options.push(def); + for (const flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + requiredArgument<N extends keyof any, V>( + name: N, + conv: Converter<V>, + args: ArgumentArgs<V> = {}, + ): CommandGroup<GN, TG & SubRecord<GN, N, V>> { + const argDef: ArgumentDef = { + args: args, + conv: conv, + name: name as string, + required: true, + }; + this.arguments.push(argDef); + return this as any; + } + + maybeArgument<N extends keyof any, V>( + name: N, + conv: Converter<V>, + args: ArgumentArgs<V> = {}, + ): CommandGroup<GN, TG & SubRecord<GN, N, V | undefined>> { + const argDef: ArgumentDef = { + args: args, + conv: conv, + name: name as string, + required: false, + }; + this.arguments.push(argDef); + return this as any; + } + + flag<N extends string, V>( + name: N, + flagspec: string[], + args: OptionArgs<V> = {}, + ): CommandGroup<GN, TG & SubRecord<GN, N, boolean>> { + const def: OptionDef = { + args: args, + flagspec: flagspec, + isFlag: true, + required: false, + name: name as string, + }; + this.options.push(def); + for (const flag of flagspec) { + if (flag.startsWith("--")) { + const flagname = flag.substring(2); + this.longOptions[flagname] = def; + } else if (flag.startsWith("-")) { + const flagname = flag.substring(1); + this.shortOptions[flagname] = def; + } else { + throw Error("option must start with '-' or '--'"); + } + } + return this as any; + } + + subcommand<GN extends keyof any>( + argKey: GN, + name: string, + args: SubcommandArgs = {}, + ): CommandGroup<GN, TG> { + const cg = new CommandGroup<GN, {}>(argKey as string, name, args); + const def: SubcommandDef = { + commandGroup: cg, + name: name as string, + args: args, + }; + cg.flag("help", ["-h", "--help"], { + help: "Show this message and exit.", + }); + this.subcommandMap[name as string] = def; + this.subcommands.push(def); + this.subcommands = this.subcommands.sort((x1, x2) => { + const a = x1.name; + const b = x2.name; + if (a === b) { + return 0; + } else if (a < b) { + return -1; + } else { + return 1; + } + }); + return cg as any; + } + + printHelp(progName: string, parents: CommandGroup<any, any>[]): void { + let usageSpec = ""; + for (const p of parents) { + usageSpec += (p.name ?? progName) + " "; + if (p.arguments.length >= 1) { + usageSpec += "<ARGS...> "; + } + } + usageSpec += (this.name ?? progName) + " "; + if (this.subcommands.length != 0) { + usageSpec += "COMMAND "; + } + for (const a of this.arguments) { + const argName = a.args.metavar ?? a.name; + usageSpec += `<${argName}> `; + } + usageSpec = usageSpec.trimRight(); + console.log(`Usage: ${usageSpec}`); + if (this.scArgs.help) { + console.log(); + console.log(this.scArgs.help); + } + if (this.options.length != 0) { + console.log(); + console.log("Options:"); + for (const opt of this.options) { + let optSpec = opt.flagspec.join(", "); + if (!opt.isFlag) { + optSpec = optSpec + "=VALUE"; + } + console.log(formatListing(optSpec, opt.args.help)); + } + } + + if (this.subcommands.length != 0) { + console.log(); + console.log("Commands:"); + for (const subcmd of this.subcommands) { + console.log(formatListing(subcmd.name, subcmd.args.help)); + } + } + } + + /** + * Run the (sub-)command with the given command line parameters. + */ + run( + progname: string, + parents: CommandGroup<any, any>[], + unparsedArgs: string[], + parsedArgs: any, + ): void { + let posArgIndex = 0; + let argsTerminated = false; + let i; + let foundSubcommand: CommandGroup<any, any> | undefined = undefined; + const myArgs: any = (parsedArgs[this.argKey] = {}); + const foundOptions: { [name: string]: boolean } = {}; + const currentName = this.name ?? progname; + for (i = 0; i < unparsedArgs.length; i++) { + const argVal = unparsedArgs[i]; + if (argsTerminated == false) { + if (argVal === "--") { + argsTerminated = true; + continue; + } + if (argVal.startsWith("--")) { + const opt = argVal.substring(2); + const r = splitOpt(opt); + const d = this.longOptions[r.key]; + if (!d) { + console.error( + `error: unknown option '--${r.key}' for ${currentName}`, + ); + process.exit(-1); + throw Error("not reached"); + } + if (d.isFlag) { + if (r.value !== undefined) { + console.error(`error: flag '--${r.key}' does not take a value`); + process.exit(-1); + throw Error("not reached"); + } + foundOptions[d.name] = true; + myArgs[d.name] = true; + } else { + if (r.value === undefined) { + if (i === unparsedArgs.length - 1) { + console.error(`error: option '--${r.key}' needs an argument`); + process.exit(-1); + throw Error("not reached"); + } + myArgs[d.name] = unparsedArgs[i + 1]; + i++; + } else { + myArgs[d.name] = r.value; + } + foundOptions[d.name] = true; + } + continue; + } + if (argVal.startsWith("-") && argVal != "-") { + const optShort = argVal.substring(1); + for (let si = 0; si < optShort.length; si++) { + const chr = optShort[si]; + const opt = this.shortOptions[chr]; + if (!opt) { + console.error(`error: option '-${chr}' not known`); + process.exit(-1); + } + if (opt.isFlag) { + myArgs[opt.name] = true; + foundOptions[opt.name] = true; + } else { + if (si == optShort.length - 1) { + if (i === unparsedArgs.length - 1) { + console.error(`error: option '-${chr}' needs an argument`); + process.exit(-1); + throw Error("not reached"); + } else { + myArgs[opt.name] = unparsedArgs[i + 1]; + i++; + } + } else { + myArgs[opt.name] = optShort.substring(si + 1); + } + foundOptions[opt.name] = true; + break; + } + } + continue; + } + } + if (this.subcommands.length != 0) { + const subcmd = this.subcommandMap[argVal]; + if (!subcmd) { + console.error(`error: unknown command '${argVal}'`); + process.exit(-1); + throw Error("not reached"); + } + foundSubcommand = subcmd.commandGroup; + break; + } else { + const d = this.arguments[posArgIndex]; + if (!d) { + console.error(`error: too many arguments for ${currentName}`); + process.exit(-1); + throw Error("not reached"); + } + myArgs[d.name] = unparsedArgs[i]; + posArgIndex++; + } + } + + if (parsedArgs[this.argKey].help) { + this.printHelp(progname, parents); + process.exit(0); + throw Error("not reached"); + } + + for (let i = posArgIndex; i < this.arguments.length; i++) { + const d = this.arguments[i]; + if (d.required) { + if (d.args.default !== undefined) { + myArgs[d.name] = d.args.default; + } else { + console.error( + `error: missing positional argument '${d.name}' for ${currentName}`, + ); + process.exit(-1); + throw Error("not reached"); + } + } + } + + for (const option of this.options) { + if (option.isFlag == false && option.required == true) { + if (!foundOptions[option.name]) { + if (option.args.default !== undefined) { + myArgs[option.name] = option.args.default; + } else { + const name = option.flagspec.join(","); + console.error(`error: missing option '${name}'`); + process.exit(-1); + throw Error("not reached"); + } + } + } + } + + for (const option of this.options) { + const ph = option.args.onPresentHandler; + if (ph && foundOptions[option.name]) { + ph(myArgs[option.name]); + } + } + + if (foundSubcommand) { + foundSubcommand.run( + progname, + Array.prototype.concat(parents, [this]), + unparsedArgs.slice(i + 1), + parsedArgs, + ); + } else if (this.myAction) { + let r; + try { + r = this.myAction(parsedArgs); + } catch (e) { + console.error(`An error occurred while running ${currentName}`); + console.error(e); + process.exit(1); + } + Promise.resolve(r).catch((e) => { + console.error(`An error occurred while running ${currentName}`); + console.error(e); + process.exit(1); + }); + } else { + this.printHelp(progname, parents); + process.exit(-1); + throw Error("not reached"); + } + } + } + + export class Program<PN extends keyof any, T> { + private mainCommand: CommandGroup<any, any>; + + constructor(argKey: string, args: ProgramArgs = {}) { + this.mainCommand = new CommandGroup<any, any>(argKey, null, { + help: args.help, + }); + this.mainCommand.flag("help", ["-h", "--help"], { + help: "Show this message and exit.", + }); + } + + run(): void { + const args = process.argv; + if (args.length < 2) { + console.error( + "Error while parsing command line arguments: not enough arguments", + ); + process.exit(-1); + } + const progname = path.basename(args[1]); + const rest = args.slice(2); + + this.mainCommand.run(progname, [], rest, {}); + } + + subcommand<GN extends keyof any>( + argKey: GN, + name: string, + args: SubcommandArgs = {}, + ): CommandGroup<GN, T> { + const cmd = this.mainCommand.subcommand(argKey, name as string, args); + return cmd as any; + } + + requiredOption<N extends keyof any, V>( + name: N, + flagspec: string[], + conv: Converter<V>, + args: OptionArgs<V> = {}, + ): Program<PN, T & SubRecord<PN, N, V>> { + this.mainCommand.requiredOption(name, flagspec, conv, args); + return this as any; + } + + maybeOption<N extends keyof any, V>( + name: N, + flagspec: string[], + conv: Converter<V>, + args: OptionArgs<V> = {}, + ): Program<PN, T & SubRecord<PN, N, V | undefined>> { + this.mainCommand.maybeOption(name, flagspec, conv, args); + return this as any; + } + + /** + * Add a flag (option without value) to the program. + */ + flag<N extends string>( + name: N, + flagspec: string[], + args: OptionArgs<boolean> = {}, + ): Program<PN, T & SubRecord<PN, N, boolean>> { + this.mainCommand.flag(name, flagspec, args); + return this as any; + } + + /** + * Add a required positional argument to the program. + */ + requiredArgument<N extends keyof any, V>( + name: N, + conv: Converter<V>, + args: ArgumentArgs<V> = {}, + ): Program<PN, T & SubRecord<PN, N, V>> { + this.mainCommand.requiredArgument(name, conv, args); + return this as any; + } + + /** + * Add an optional argument to the program. + */ + maybeArgument<N extends keyof any, V>( + name: N, + conv: Converter<V>, + args: ArgumentArgs<V> = {}, + ): Program<PN, T & SubRecord<PN, N, V | undefined>> { + this.mainCommand.maybeArgument(name, conv, args); + return this as any; + } + + action(f: ActionFn<T>): void { + this.mainCommand.action(f); + } + } + + export type GetArgType<T> = T extends Program<any, infer AT> + ? AT + : T extends CommandGroup<any, infer AT> + ? AT + : any; + + export function program<PN extends keyof any>( + argKey: PN, + args: ProgramArgs = {}, + ): Program<PN, {}> { + return new Program(argKey as string, args); + } + + export function prompt(question: string): Promise<string> { + const stdinReadline = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + return new Promise<string>((resolve, reject) => { + stdinReadline.question(question, (res) => { + resolve(res); + stdinReadline.close(); + }); + }); + } +} diff --git a/packages/taler-util/src/http-status-codes.ts b/packages/taler-util/src/http-status-codes.ts new file mode 100644 index 000000000..848839990 --- /dev/null +++ b/packages/taler-util/src/http-status-codes.ts @@ -0,0 +1,379 @@ +/** + * Hypertext Transfer Protocol (HTTP) response status codes. + * + * @see {@link https://en.wikipedia.org/wiki/List_of_HTTP_status_codes} + */ +export enum HttpStatusCode { + /** + * The server has received the request headers and the client should proceed to send the request body + * (in the case of a request for which a body needs to be sent; for example, a POST request). + * Sending a large request body to a server after a request has been rejected for inappropriate headers would be inefficient. + * To have a server check the request's headers, a client must send Expect: 100-continue as a header in its initial request + * and receive a 100 Continue status code in response before sending the body. The response 417 Expectation Failed indicates the request should not be continued. + */ + Continue = 100, + + /** + * The requester has asked the server to switch protocols and the server has agreed to do so. + */ + SwitchingProtocols = 101, + + /** + * A WebDAV request may contain many sub-requests involving file operations, requiring a long time to complete the request. + * This code indicates that the server has received and is processing the request, but no response is available yet. + * This prevents the client from timing out and assuming the request was lost. + */ + Processing = 102, + + /** + * Standard response for successful HTTP requests. + * The actual response will depend on the request method used. + * In a GET request, the response will contain an entity corresponding to the requested resource. + * In a POST request, the response will contain an entity describing or containing the result of the action. + */ + Ok = 200, + + /** + * The request has been fulfilled, resulting in the creation of a new resource. + */ + Created = 201, + + /** + * The request has been accepted for processing, but the processing has not been completed. + * The request might or might not be eventually acted upon, and may be disallowed when processing occurs. + */ + Accepted = 202, + + /** + * SINCE HTTP/1.1 + * The server is a transforming proxy that received a 200 OK from its origin, + * but is returning a modified version of the origin's response. + */ + NonAuthoritativeInformation = 203, + + /** + * The server successfully processed the request and is not returning any content. + */ + NoContent = 204, + + /** + * The server successfully processed the request, but is not returning any content. + * Unlike a 204 response, this response requires that the requester reset the document view. + */ + ResetContent = 205, + + /** + * The server is delivering only part of the resource (byte serving) due to a range header sent by the client. + * The range header is used by HTTP clients to enable resuming of interrupted downloads, + * or split a download into multiple simultaneous streams. + */ + PartialContent = 206, + + /** + * The message body that follows is an XML message and can contain a number of separate response codes, + * depending on how many sub-requests were made. + */ + MultiStatus = 207, + + /** + * The members of a DAV binding have already been enumerated in a preceding part of the (multistatus) response, + * and are not being included again. + */ + AlreadyReported = 208, + + /** + * The server has fulfilled a request for the resource, + * and the response is a representation of the result of one or more instance-manipulations applied to the current instance. + */ + ImUsed = 226, + + /** + * Indicates multiple options for the resource from which the client may choose (via agent-driven content negotiation). + * For example, this code could be used to present multiple video format options, + * to list files with different filename extensions, or to suggest word-sense disambiguation. + */ + MultipleChoices = 300, + + /** + * This and all future requests should be directed to the given URI. + */ + MovedPermanently = 301, + + /** + * This is an example of industry practice contradicting the standard. + * The HTTP/1.0 specification (RFC 1945) required the client to perform a temporary redirect + * (the original describing phrase was "Moved Temporarily"), but popular browsers implemented 302 + * with the functionality of a 303 See Other. Therefore, HTTP/1.1 added status codes 303 and 307 + * to distinguish between the two behaviours. However, some Web applications and frameworks + * use the 302 status code as if it were the 303. + */ + Found = 302, + + /** + * SINCE HTTP/1.1 + * The response to the request can be found under another URI using a GET method. + * When received in response to a POST (or PUT/DELETE), the client should presume that + * the server has received the data and should issue a redirect with a separate GET message. + */ + SeeOther = 303, + + /** + * Indicates that the resource has not been modified since the version specified by the request headers If-Modified-Since or If-None-Match. + * In such case, there is no need to retransmit the resource since the client still has a previously-downloaded copy. + */ + NotModified = 304, + + /** + * SINCE HTTP/1.1 + * The requested resource is available only through a proxy, the address for which is provided in the response. + * Many HTTP clients (such as Mozilla and Internet Explorer) do not correctly handle responses with this status code, primarily for security reasons. + */ + UseProxy = 305, + + /** + * No longer used. Originally meant "Subsequent requests should use the specified proxy." + */ + SwitchProxy = 306, + + /** + * SINCE HTTP/1.1 + * In this case, the request should be repeated with another URI; however, future requests should still use the original URI. + * In contrast to how 302 was historically implemented, the request method is not allowed to be changed when reissuing the original request. + * For example, a POST request should be repeated using another POST request. + */ + TemporaryRedirect = 307, + + /** + * The request and all future requests should be repeated using another URI. + * 307 and 308 parallel the behaviors of 302 and 301, but do not allow the HTTP method to change. + * So, for example, submitting a form to a permanently redirected resource may continue smoothly. + */ + PermanentRedirect = 308, + + /** + * The server cannot or will not process the request due to an apparent client error + * (e.g., malformed request syntax, too large size, invalid request message framing, or deceptive request routing). + */ + BadRequest = 400, + + /** + * Similar to 403 Forbidden, but specifically for use when authentication is required and has failed or has not yet + * been provided. The response must include a WWW-Authenticate header field containing a challenge applicable to the + * requested resource. See Basic access authentication and Digest access authentication. 401 semantically means + * "unauthenticated",i.e. the user does not have the necessary credentials. + */ + Unauthorized = 401, + + /** + * Reserved for future use. The original intention was that this code might be used as part of some form of digital + * cash or micro payment scheme, but that has not happened, and this code is not usually used. + * Google Developers API uses this status if a particular developer has exceeded the daily limit on requests. + */ + PaymentRequired = 402, + + /** + * The request was valid, but the server is refusing action. + * The user might not have the necessary permissions for a resource. + */ + Forbidden = 403, + + /** + * The requested resource could not be found but may be available in the future. + * Subsequent requests by the client are permissible. + */ + NotFound = 404, + + /** + * A request method is not supported for the requested resource; + * for example, a GET request on a form that requires data to be presented via POST, or a PUT request on a read-only resource. + */ + MethodNotAllowed = 405, + + /** + * The requested resource is capable of generating only content not acceptable according to the Accept headers sent in the request. + */ + NotAcceptable = 406, + + /** + * The client must first authenticate itself with the proxy. + */ + ProxyAuthenticationRequired = 407, + + /** + * The server timed out waiting for the request. + * According to HTTP specifications: + * "The client did not produce a request within the time that the server was prepared to wait. The client MAY repeat the request without modifications at any later time." + */ + RequestTimeout = 408, + + /** + * Indicates that the request could not be processed because of conflict in the request, + * such as an edit conflict between multiple simultaneous updates. + */ + Conflict = 409, + + /** + * Indicates that the resource requested is no longer available and will not be available again. + * This should be used when a resource has been intentionally removed and the resource should be purged. + * Upon receiving a 410 status code, the client should not request the resource in the future. + * Clients such as search engines should remove the resource from their indices. + * Most use cases do not require clients and search engines to purge the resource, and a "404 Not Found" may be used instead. + */ + Gone = 410, + + /** + * The request did not specify the length of its content, which is required by the requested resource. + */ + LengthRequired = 411, + + /** + * The server does not meet one of the preconditions that the requester put on the request. + */ + PreconditionFailed = 412, + + /** + * The request is larger than the server is willing or able to process. Previously called "Request Entity Too Large". + */ + PayloadTooLarge = 413, + + /** + * The URI provided was too long for the server to process. Often the result of too much data being encoded as a query-string of a GET request, + * in which case it should be converted to a POST request. + * Called "Request-URI Too Long" previously. + */ + UriTooLong = 414, + + /** + * The request entity has a media type which the server or resource does not support. + * For example, the client uploads an image as image/svg+xml, but the server requires that images use a different format. + */ + UnsupportedMediaType = 415, + + /** + * The client has asked for a portion of the file (byte serving), but the server cannot supply that portion. + * For example, if the client asked for a part of the file that lies beyond the end of the file. + * Called "Requested Range Not Satisfiable" previously. + */ + RangeNotSatisfiable = 416, + + /** + * The server cannot meet the requirements of the Expect request-header field. + */ + ExpectationFailed = 417, + + /** + * This code was defined in 1998 as one of the traditional IETF April Fools' jokes, in RFC 2324, Hyper Text Coffee Pot Control Protocol, + * and is not expected to be implemented by actual HTTP servers. The RFC specifies this code should be returned by + * teapots requested to brew coffee. This HTTP status is used as an Easter egg in some websites, including Google.com. + */ + IAmATeapot = 418, + + /** + * The request was directed at a server that is not able to produce a response (for example because a connection reuse). + */ + MisdirectedRequest = 421, + + /** + * The request was well-formed but was unable to be followed due to semantic errors. + */ + UnprocessableEntity = 422, + + /** + * The resource that is being accessed is locked. + */ + Locked = 423, + + /** + * The request failed due to failure of a previous request (e.g., a PROPPATCH). + */ + FailedDependency = 424, + + /** + * The client should switch to a different protocol such as TLS/1.0, given in the Upgrade header field. + */ + UpgradeRequired = 426, + + /** + * The origin server requires the request to be conditional. + * Intended to prevent "the 'lost update' problem, where a client + * GETs a resource's state, modifies it, and PUTs it back to the server, + * when meanwhile a third party has modified the state on the server, leading to a conflict." + */ + PreconditionRequired = 428, + + /** + * The user has sent too many requests in a given amount of time. Intended for use with rate-limiting schemes. + */ + TooManyRequests = 429, + + /** + * The server is unwilling to process the request because either an individual header field, + * or all the header fields collectively, are too large. + */ + RequestHeaderFieldsTooLarge = 431, + + /** + * A server operator has received a legal demand to deny access to a resource or to a set of resources + * that includes the requested resource. The code 451 was chosen as a reference to the novel Fahrenheit 451. + */ + UnavailableForLegalReasons = 451, + + /** + * A generic error message, given when an unexpected condition was encountered and no more specific message is suitable. + */ + InternalServerError = 500, + + /** + * The server either does not recognize the request method, or it lacks the ability to fulfill the request. + * Usually this implies future availability (e.g., a new feature of a web-service API). + */ + NotImplemented = 501, + + /** + * The server was acting as a gateway or proxy and received an invalid response from the upstream server. + */ + BadGateway = 502, + + /** + * The server is currently unavailable (because it is overloaded or down for maintenance). + * Generally, this is a temporary state. + */ + ServiceUnavailable = 503, + + /** + * The server was acting as a gateway or proxy and did not receive a timely response from the upstream server. + */ + GatewayTimeout = 504, + + /** + * The server does not support the HTTP protocol version used in the request + */ + HttpVersionNotSupported = 505, + + /** + * Transparent content negotiation for the request results in a circular reference. + */ + VariantAlsoNegotiates = 506, + + /** + * The server is unable to store the representation needed to complete the request. + */ + InsufficientStorage = 507, + + /** + * The server detected an infinite loop while processing the request. + */ + LoopDetected = 508, + + /** + * Further extensions to the request are required for the server to fulfill it. + */ + NotExtended = 510, + + /** + * The client needs to authenticate to gain network access. + * Intended for use by intercepting proxies used to control access to the network (e.g., "captive portals" used + * to require agreement to Terms of Service before granting full Internet access via a Wi-Fi hotspot). + */ + NetworkAuthenticationRequired = 511, +} diff --git a/packages/taler-util/src/index.node.ts b/packages/taler-util/src/index.node.ts index 018b4767f..bd59f320a 100644 --- a/packages/taler-util/src/index.node.ts +++ b/packages/taler-util/src/index.node.ts @@ -21,3 +21,4 @@ initNodePrng(); export * from "./index.js"; export * from "./talerconfig.js"; export * from "./globbing/minimatch.js"; +export { clk } from "./clk.js"; diff --git a/packages/taler-util/src/index.ts b/packages/taler-util/src/index.ts index 4ad752954..c42e5e66a 100644 --- a/packages/taler-util/src/index.ts +++ b/packages/taler-util/src/index.ts @@ -22,6 +22,7 @@ export * from "./url.js"; export { fnutil } from "./fnutils.js"; export * from "./kdf.js"; export * from "./talerCrypto.js"; +export * from "./http-status-codes.js"; export { randomBytes, secretbox, diff --git a/packages/taler-util/src/logging.ts b/packages/taler-util/src/logging.ts index 0037d95a3..8b9de1ab0 100644 --- a/packages/taler-util/src/logging.ts +++ b/packages/taler-util/src/logging.ts @@ -23,6 +23,47 @@ const isNode = typeof process.release !== "undefined" && process.release.name === "node"; +export enum LogLevel { + Trace = "trace", + Message = "message", + Info = "info", + Warn = "warn", + Error = "error", + None = "none", +} + +export let globalLogLevel = LogLevel.Info; + +export function setGlobalLogLevelFromString(logLevelStr: string) { + let level: LogLevel; + switch (logLevelStr.toLowerCase()) { + case "trace": + level = LogLevel.Trace; + break; + case "info": + level = LogLevel.Info; + break; + case "warn": + case "warning": + level = LogLevel.Warn; + break; + case "error": + level = LogLevel.Error; + break; + case "none": + level = LogLevel.None; + break; + default: + if (isNode) { + process.stderr.write(`Invalid log level, defaulting to WARNING`); + } else { + console.warn(`Invalid log level, defaulting to WARNING`); + } + level = LogLevel.Warn; + } + globalLogLevel = level; +} + function writeNodeLog( message: any, tag: string, @@ -57,21 +98,60 @@ export class Logger { constructor(private tag: string) {} shouldLogTrace() { - // FIXME: Implement logic to check loglevel - return true; + switch (globalLogLevel) { + case LogLevel.Trace: + return true; + case LogLevel.Message: + case LogLevel.Info: + case LogLevel.Warn: + case LogLevel.Error: + case LogLevel.None: + return false; + } } shouldLogInfo() { - // FIXME: Implement logic to check loglevel - return true; + switch (globalLogLevel) { + case LogLevel.Trace: + case LogLevel.Message: + case LogLevel.Info: + return true; + case LogLevel.Warn: + case LogLevel.Error: + case LogLevel.None: + return false; + } } shouldLogWarn() { - // FIXME: Implement logic to check loglevel - return true; + switch (globalLogLevel) { + case LogLevel.Trace: + case LogLevel.Message: + case LogLevel.Info: + case LogLevel.Warn: + return true; + case LogLevel.Error: + case LogLevel.None: + return false; + } + } + + shouldLogError() { + switch (globalLogLevel) { + case LogLevel.Trace: + case LogLevel.Message: + case LogLevel.Info: + case LogLevel.Warn: + case LogLevel.Error: + case LogLevel.None: + return false; + } } info(message: string, ...args: any[]): void { + if (!this.shouldLogInfo()) { + return; + } if (isNode) { writeNodeLog(message, this.tag, "INFO", args); } else { @@ -83,6 +163,9 @@ export class Logger { } warn(message: string, ...args: any[]): void { + if (!this.shouldLogWarn()) { + return; + } if (isNode) { writeNodeLog(message, this.tag, "WARN", args); } else { @@ -94,6 +177,9 @@ export class Logger { } error(message: string, ...args: any[]): void { + if (!this.shouldLogError()) { + return; + } if (isNode) { writeNodeLog(message, this.tag, "ERROR", args); } else { @@ -105,6 +191,9 @@ export class Logger { } trace(message: any, ...args: any[]): void { + if (!this.shouldLogTrace()) { + return; + } if (isNode) { writeNodeLog(message, this.tag, "TRACE", args); } else { diff --git a/packages/taler-util/src/time.ts b/packages/taler-util/src/time.ts index c0858ada6..856db8a57 100644 --- a/packages/taler-util/src/time.ts +++ b/packages/taler-util/src/time.ts @@ -69,6 +69,20 @@ export function getDurationRemaining( return { d_ms: deadline.t_ms - now.t_ms }; } +export namespace Duration { + export const getRemaining = getDurationRemaining; + export function toIntegerYears(d: Duration): number { + if (typeof d.d_ms !== "number") { + throw Error("infinite duration"); + } + return Math.ceil(d.d_ms / 1000 / 60 / 60 / 24 / 365); + } +} + +export namespace Timestamp { + export const min = timestampMin; +} + export function timestampMin(t1: Timestamp, t2: Timestamp): Timestamp { if (t1.t_ms === "never") { return { t_ms: t2.t_ms }; diff --git a/packages/taler-wallet-cli/src/bench1.ts b/packages/taler-wallet-cli/src/bench1.ts index 448dc913d..3d4561097 100644 --- a/packages/taler-wallet-cli/src/bench1.ts +++ b/packages/taler-wallet-cli/src/bench1.ts @@ -22,6 +22,7 @@ import { codecForNumber, codecForString, codecOptional, + Logger, } from "@gnu-taler/taler-util"; import { getDefaultNodeWallet, @@ -36,6 +37,9 @@ import { * set up its own services. */ export async function runBench1(configJson: any): Promise<void> { + + const logger = new Logger("Bench1"); + // Validate the configuration file for this benchmark. const b1conf = codecForBench1Config().decode(configJson); @@ -47,8 +51,9 @@ export async function runBench1(configJson: any): Promise<void> { const withdrawAmount = (numDeposits + 1) * 10; + logger.info(`Starting Benchmark with ${numIter} Iterations`); + for (let i = 0; i < numIter; i++) { - // Create a new wallet in each iteration // otherwise the TPS go down // my assumption is that the in-memory db file gets too large @@ -59,6 +64,8 @@ export async function runBench1(configJson: any): Promise<void> { }); await wallet.client.call(WalletApiOperation.InitWallet, {}); + logger.info(`Starting withdrawal of ${withdrawAmount} ${b1conf.currency}`); + await wallet.client.call(WalletApiOperation.WithdrawFakebank, { amount: b1conf.currency + ":" + withdrawAmount, bank: b1conf.bank, @@ -69,7 +76,11 @@ export async function runBench1(configJson: any): Promise<void> { stopWhenDone: true, }); + logger.info(`Finished withdrawal`); + for (let i = 0; i < numDeposits; i++) { + + logger.info(`Starting deposit of 10 ${b1conf.currency}`); await wallet.client.call(WalletApiOperation.CreateDepositGroup, { amount: b1conf.currency + ":10", depositPaytoUri: b1conf.payto, @@ -78,6 +89,7 @@ export async function runBench1(configJson: any): Promise<void> { await wallet.runTaskLoop({ stopWhenDone: true, }); + logger.info(`Deposit succesful`); } wallet.stop(); diff --git a/packages/taler-wallet-cli/src/harness/harness.ts b/packages/taler-wallet-cli/src/harness/harness.ts index b4ac16dbf..f8dd15738 100644 --- a/packages/taler-wallet-cli/src/harness/harness.ts +++ b/packages/taler-wallet-cli/src/harness/harness.ts @@ -67,6 +67,7 @@ import { getRandomBytes, } from "@gnu-taler/taler-util"; import { CoinConfig } from "./denomStructures.js"; +import { LibeufinNexusApi, LibeufinSandboxApi } from "./libeufin-apis.js"; const exec = util.promisify(require("child_process").exec); @@ -607,24 +608,202 @@ export namespace BankApi { } } -export class BankService implements BankServiceInterface { + +class BankServiceBase { proc: ProcessWrapper | undefined; - static fromExistingConfig(gc: GlobalTestState): BankService { - const cfgFilename = gc.testDir + "/bank.conf"; - console.log("reading bank config from", cfgFilename); - const config = Configuration.load(cfgFilename); - const bc: BankConfig = { - allowRegistrations: config - .getYesNo("bank", "allow_registrations") - .required(), - currency: config.getString("taler", "currency").required(), - database: config.getString("bank", "database").required(), - httpPort: config.getNumber("bank", "http_port").required(), + protected constructor( + protected globalTestState: GlobalTestState, + protected bankConfig: BankConfig, + protected configFile: string, + ) {} +} + +/** + * Work in progress. The key point is that both Sandbox and Nexus + * will be configured and started by this class. + */ +class LibeufinBankService extends BankServiceBase implements BankService { + sandboxProc: ProcessWrapper | undefined; + nexusProc: ProcessWrapper | undefined; + + static async create( + gc: GlobalTestState, + bc: BankConfig, + ): Promise<BankService> { + + return new LibeufinBankService(gc, bc, "foo"); + } + + get port() { + return this.bankConfig.httpPort; + } + + get nexusBaseUrl(): string { + return `http://localhost:${this.bankConfig.httpPort + 1}`; + } + + get baseUrl(): string { + return `http://localhost:${this.bankConfig.httpPort}/demobanks/default/access-api`; + } + + async setSuggestedExchange( + e: ExchangeServiceInterface, + exchangePayto: string + ) { + await sh( + this.globalTestState, + "libeufin-sandbox-set-default-exchange", + `libeufin-sandbox default-exchange ${exchangePayto}` + ); + } + + // Create one at both sides: Sandbox and Nexus. + async createExchangeAccount( + accountName: string, + password: string, + ): Promise<HarnessExchangeBankAccount> { + + await LibeufinSandboxApi.createDemobankAccount( + accountName, + password, + { baseUrl: this.baseUrl } + ); + let bankAccountLabel = `${accountName}-acct` + await LibeufinSandboxApi.createDemobankEbicsSubscriber( + { + hostID: "talertest-ebics-host", + userID: "exchange-ebics-user", + partnerID: "exchange-ebics-partner", + }, + bankAccountLabel, + { baseUrl: this.baseUrl } + ); + + await LibeufinNexusApi.createUser( + { baseUrl: this.nexusBaseUrl }, + { + username: `${accountName}-nexus-username`, + password: `${password}-nexus-password` + } + ); + await LibeufinNexusApi.createEbicsBankConnection( + { baseUrl: this.nexusBaseUrl }, + { + name: "ebics-connection", // connection name. + ebicsURL: `http://localhost:${this.bankConfig.httpPort}/ebicsweb`, + hostID: "talertest-ebics-host", + userID: "exchange-ebics-user", + partnerID: "exchange-ebics-partner", + } + ); + await LibeufinNexusApi.connectBankConnection( + { baseUrl: this.nexusBaseUrl }, "ebics-connection" + ); + await LibeufinNexusApi.fetchAccounts( + { baseUrl: this.nexusBaseUrl }, "ebics-connection" + ); + await LibeufinNexusApi.importConnectionAccount( + { baseUrl: this.nexusBaseUrl }, + "ebics-connection", // connection name + `${accountName}-acct`, // offered account label + `${accountName}-nexus-label` // bank account label at Nexus + ); + await LibeufinNexusApi.createTwgFacade( + { baseUrl: this.nexusBaseUrl }, + { + name: "exchange-facade", + connectionName: "ebics-connection", + accountName: `${accountName}-nexus-label`, + currency: "EUR", + reserveTransferLevel: "report" + } + ); + await LibeufinNexusApi.postPermission( + { baseUrl: this.nexusBaseUrl }, + { + action: "grant", + permission: { + subjectId: `${accountName}-nexus-username`, + subjectType: "user", + resourceType: "facade", + resourceId: "exchange-facade", // facade name + permissionName: "facade.talerWireGateway.transfer", + }, + } + ); + await LibeufinNexusApi.postPermission( + { baseUrl: this.nexusBaseUrl }, + { + action: "grant", + permission: { + subjectId: `${accountName}-nexus-username`, + subjectType: "user", + resourceType: "facade", + resourceId: "exchange-facade", // facade name + permissionName: "facade.talerWireGateway.history", + }, + } + ); + let facadesResp = await LibeufinNexusApi.getAllFacades({ baseUrl: this.nexusBaseUrl }); + let accountInfoResp = await LibeufinSandboxApi.demobankAccountInfo( + accountName, // username + password, + { baseUrl: this.nexusBaseUrl }, + `${accountName}acct` // bank account label. + ); + return { + accountName: accountName, + accountPassword: password, + accountPaytoUri: accountInfoResp.data.paytoUri, + wireGatewayApiBaseUrl: facadesResp.data.facades[0].baseUrl, }; - return new BankService(gc, bc, cfgFilename); } + async start(): Promise<void> { + let sandboxDb = `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-sandbox.sqlite3`; + let nexusDb = `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-nexus.sqlite3`; + this.sandboxProc = this.globalTestState.spawnService( + "libeufin-sandbox", + ["serve", "--port", `${this.port}`], + "libeufin-sandbox", + { + ...process.env, + LIBEUFIN_SANDBOX_DB_CONNECTION: sandboxDb, + LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret", + }, + ); + await runCommand( + this.globalTestState, + "libeufin-nexus-superuser", + "libeufin-nexus", + ["superuser", "admin", "--password", "test"], + { + ...process.env, + LIBEUFIN_NEXUS_DB_CONNECTION: nexusDb, + }, + ); + + this.nexusProc = this.globalTestState.spawnService( + "libeufin-nexus", + ["serve", "--port", `${this.port + 1}`], + "libeufin-nexus", + { + ...process.env, + LIBEUFIN_NEXUS_DB_CONNECTION: nexusDb, + }, + ); + } + + async pingUntilAvailable(): Promise<void> { + await pingProc(this.sandboxProc, this.baseUrl, "libeufin-sandbox"); + await pingProc(this.nexusProc, `${this.baseUrl}config`, "libeufin-nexus"); + } +} + +export class BankService extends BankServiceBase implements BankServiceInterface { + proc: ProcessWrapper | undefined; + static async create( gc: GlobalTestState, bc: BankConfig, @@ -700,12 +879,6 @@ export class BankService implements BankServiceInterface { return this.bankConfig.httpPort; } - private constructor( - private globalTestState: GlobalTestState, - private bankConfig: BankConfig, - private configFile: string, - ) {} - async start(): Promise<void> { this.proc = this.globalTestState.spawnService( "taler-bank-manage", @@ -720,6 +893,12 @@ export class BankService implements BankServiceInterface { } } +// Still work in progress.. +if (false && process.env.WALLET_HARNESS_WITH_EUFIN) { + BankService.create = LibeufinBankService.create; + BankService.prototype = Object.create(LibeufinBankService.prototype); +} + export class FakeBankService { proc: ProcessWrapper | undefined; diff --git a/packages/taler-wallet-cli/src/harness/helpers.ts b/packages/taler-wallet-cli/src/harness/helpers.ts index 3b4e1643f..6ff62504b 100644 --- a/packages/taler-wallet-cli/src/harness/helpers.ts +++ b/packages/taler-wallet-cli/src/harness/helpers.ts @@ -62,16 +62,6 @@ export interface SimpleTestEnvironment { wallet: WalletCli; } -export function getRandomIban(countryCode: string): string { - return `${countryCode}715001051796${(Math.random() * 100000000) - .toString() - .substring(0, 6)}`; -} - -export function getRandomString(): string { - return Math.random().toString(36).substring(2); -} - /** * Run a test case with a simple TESTKUDOS Taler environment, consisting * of one exchange, one bank and one merchant. diff --git a/packages/taler-wallet-cli/src/harness/libeufin-apis.ts b/packages/taler-wallet-cli/src/harness/libeufin-apis.ts new file mode 100644 index 000000000..68a25d92f --- /dev/null +++ b/packages/taler-wallet-cli/src/harness/libeufin-apis.ts @@ -0,0 +1,857 @@ + +/** + * This file defines most of the API calls offered + * by Nexus and Sandbox. They don't have state, + * therefore got moved away from libeufin.ts where + * the services get actually started and managed. + */ + + +import axios from "axios"; +import { URL } from "@gnu-taler/taler-util"; + +export interface LibeufinSandboxServiceInterface { + baseUrl: string; +} + +export interface LibeufinNexusServiceInterface { + baseUrl: string; +} + +export interface CreateEbicsSubscriberRequest { + hostID: string; + userID: string; + partnerID: string; + systemID?: string; +} + +export interface BankAccountInfo { + iban: string; + bic: string; + name: string; + label: string; +} + +export interface CreateEbicsBankConnectionRequest { + name: string; // connection name. + ebicsURL: string; + hostID: string; + userID: string; + partnerID: string; + systemID?: string; +} + +export interface UpdateNexusUserRequest { + newPassword: string; +} + +export interface NexusAuth { + auth: { + username: string; + password: string; + }; +} + +export interface PostNexusTaskRequest { + name: string; + cronspec: string; + type: string; // fetch | submit + params: + | { + level: string; // report | statement | all + rangeType: string; // all | since-last | previous-days | latest + } + | {}; +} + +export interface CreateNexusUserRequest { + username: string; + password: string; +} + +export interface PostNexusPermissionRequest { + action: "revoke" | "grant"; + permission: { + subjectType: string; + subjectId: string; + resourceType: string; + resourceId: string; + permissionName: string; + }; +} + + +export interface CreateAnastasisFacadeRequest { + name: string; + connectionName: string; + accountName: string; + currency: string; + reserveTransferLevel: "report" | "statement" | "notification"; +} + +export interface CreateTalerWireGatewayFacadeRequest { + name: string; + connectionName: string; + accountName: string; + currency: string; + reserveTransferLevel: "report" | "statement" | "notification"; +} + +export interface SandboxAccountTransactions { + payments: { + accountLabel: string; + creditorIban: string; + creditorBic?: string; + creditorName: string; + debtorIban: string; + debtorBic: string; + debtorName: string; + amount: string; + currency: string; + subject: string; + date: string; + creditDebitIndicator: "debit" | "credit"; + accountServicerReference: string; + }[]; +} + +export interface DeleteBankConnectionRequest { + bankConnectionId: string; +} + +export interface SimulateIncomingTransactionRequest { + debtorIban: string; + debtorBic: string; + debtorName: string; + + /** + * Subject / unstructured remittance info. + */ + subject: string; + + /** + * Decimal amount without currency. + */ + amount: string; +} + + +export interface CreateEbicsBankAccountRequest { + subscriber: { + hostID: string; + partnerID: string; + userID: string; + systemID?: string; + }; + // IBAN + iban: string; + // BIC + bic: string; + // human name + name: string; + label: string; +} + +export interface LibeufinSandboxAddIncomingRequest { + creditorIban: string; + creditorBic: string; + creditorName: string; + debtorIban: string; + debtorBic: string; + debtorName: string; + subject: string; + amount: string; + currency: string; + uid: string; + direction: string; +} + +function getRandomString(): string { + return Math.random().toString(36).substring(2); +} + +export namespace LibeufinSandboxApi { + + /** + * Return balance and payto-address of 'accountLabel'. + * Note: the demobank serving the request is hard-coded + * inside the base URL, and therefore contained in + * 'libeufinSandboxService'. + */ + export async function demobankAccountInfo( + username: string, + password: string, + libeufinSandboxService: LibeufinSandboxServiceInterface, + accountLabel: string + ) { + let url = new URL(`${libeufinSandboxService.baseUrl}/accounts/${accountLabel}`); + return await axios.get(url.href, { + auth: { + username: username, + password: password + } + }); + } + + // Creates one bank account via the Access API. + export async function createDemobankAccount( + username: string, + password: string, + libeufinSandboxService: LibeufinSandboxServiceInterface, + ) { + let url = new URL(`${libeufinSandboxService.baseUrl}/testing/register`); + await axios.post(url.href, { + username: username, + password: password + }); + } + + export async function createDemobankEbicsSubscriber( + req: CreateEbicsSubscriberRequest, + demobankAccountLabel: string, + libeufinSandboxService: LibeufinSandboxServiceInterface, + username: string = "admin", + password: string = "secret", + ) { + // baseUrl should already be pointed to one demobank. + let url = new URL(libeufinSandboxService.baseUrl); + await axios.post(url.href, { + userID: req.userID, + hostID: req.hostID, + partnerID: req.userID, + demobankAccountLabel: demobankAccountLabel, + }, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function rotateKeys( + libeufinSandboxService: LibeufinSandboxServiceInterface, + hostID: string, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL(`admin/ebics/hosts/${hostID}/rotate-keys`, baseUrl); + await axios.post(url.href, {}, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + export async function createEbicsHost( + libeufinSandboxService: LibeufinSandboxServiceInterface, + hostID: string, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL("admin/ebics/hosts", baseUrl); + await axios.post(url.href, { + hostID, + ebicsVersion: "2.5", + }, + { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function createBankAccount( + libeufinSandboxService: LibeufinSandboxServiceInterface, + req: BankAccountInfo, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL(`admin/bank-accounts/${req.label}`, baseUrl); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + /** + * This function is useless. It creates a Ebics subscriber + * but never gives it a bank account. To be removed + */ + export async function createEbicsSubscriber( + libeufinSandboxService: LibeufinSandboxServiceInterface, + req: CreateEbicsSubscriberRequest, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL("admin/ebics/subscribers", baseUrl); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function createEbicsBankAccount( + libeufinSandboxService: LibeufinSandboxServiceInterface, + req: CreateEbicsBankAccountRequest, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL("admin/ebics/bank-accounts", baseUrl); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function simulateIncomingTransaction( + libeufinSandboxService: LibeufinSandboxServiceInterface, + accountLabel: string, + req: SimulateIncomingTransactionRequest, + ) { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL( + `admin/bank-accounts/${accountLabel}/simulate-incoming-transaction`, + baseUrl, + ); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function getAccountTransactions( + libeufinSandboxService: LibeufinSandboxServiceInterface, + accountLabel: string, + ): Promise<SandboxAccountTransactions> { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL( + `admin/bank-accounts/${accountLabel}/transactions`, + baseUrl, + ); + const res = await axios.get(url.href, { + auth: { + username: "admin", + password: "secret", + }, + }); + return res.data as SandboxAccountTransactions; + } + + export async function getCamt053( + libeufinSandboxService: LibeufinSandboxServiceInterface, + accountLabel: string, + ): Promise<any> { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL("admin/payments/camt", baseUrl); + return await axios.post(url.href, { + bankaccount: accountLabel, + type: 53, + }, + { + auth: { + username: "admin", + password: "secret", + }, + }); + } + + export async function getAccountInfoWithBalance( + libeufinSandboxService: LibeufinSandboxServiceInterface, + accountLabel: string, + ): Promise<any> { + const baseUrl = libeufinSandboxService.baseUrl; + let url = new URL( + `admin/bank-accounts/${accountLabel}`, + baseUrl, + ); + return await axios.get(url.href, { + auth: { + username: "admin", + password: "secret", + }, + }); + } +} + +export namespace LibeufinNexusApi { + export async function getAllConnections( + nexus: LibeufinNexusServiceInterface, + ): Promise<any> { + let url = new URL("bank-connections", nexus.baseUrl); + const res = await axios.get(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + return res; + } + + export async function deleteBankConnection( + libeufinNexusService: LibeufinNexusServiceInterface, + req: DeleteBankConnectionRequest, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL("bank-connections/delete-connection", baseUrl); + return await axios.post(url.href, req, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function createEbicsBankConnection( + libeufinNexusService: LibeufinNexusServiceInterface, + req: CreateEbicsBankConnectionRequest, + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL("bank-connections", baseUrl); + await axios.post( + url.href, + { + source: "new", + type: "ebics", + name: req.name, + data: { + ebicsURL: req.ebicsURL, + hostID: req.hostID, + userID: req.userID, + partnerID: req.partnerID, + systemID: req.systemID, + }, + }, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function getBankAccount( + libeufinNexusService: LibeufinNexusServiceInterface, + accountName: string, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `bank-accounts/${accountName}`, + baseUrl, + ); + return await axios.get( + url.href, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + + export async function submitInitiatedPayment( + libeufinNexusService: LibeufinNexusServiceInterface, + accountName: string, + paymentId: string, + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `bank-accounts/${accountName}/payment-initiations/${paymentId}/submit`, + baseUrl, + ); + await axios.post( + url.href, + {}, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function fetchAccounts( + libeufinNexusService: LibeufinNexusServiceInterface, + connectionName: string, + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `bank-connections/${connectionName}/fetch-accounts`, + baseUrl, + ); + await axios.post( + url.href, + {}, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function importConnectionAccount( + libeufinNexusService: LibeufinNexusServiceInterface, + connectionName: string, + offeredAccountId: string, + nexusBankAccountId: string, + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `bank-connections/${connectionName}/import-account`, + baseUrl, + ); + await axios.post( + url.href, + { + offeredAccountId, + nexusBankAccountId, + }, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function connectBankConnection( + libeufinNexusService: LibeufinNexusServiceInterface, + connectionName: string, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`bank-connections/${connectionName}/connect`, baseUrl); + await axios.post( + url.href, + {}, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function getPaymentInitiations( + libeufinNexusService: LibeufinNexusServiceInterface, + accountName: string, + username: string = "admin", + password: string = "test", + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `/bank-accounts/${accountName}/payment-initiations`, + baseUrl, + ); + let response = await axios.get(url.href, { + auth: { + username: username, + password: password, + }, + }); + console.log( + `Payment initiations of: ${accountName}`, + JSON.stringify(response.data, null, 2), + ); + } + + export async function getConfig( + libeufinNexusService: LibeufinNexusServiceInterface, + ): Promise<void> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/config`, baseUrl); + let response = await axios.get(url.href); + } + + // Uses the Anastasis API to get a list of transactions. + export async function getAnastasisTransactions( + libeufinNexusService: LibeufinNexusServiceInterface, + anastasisBaseUrl: string, + params: {}, // of the request: {delta: 5, ..} + username: string = "admin", + password: string = "test", + ): Promise<any> { + let url = new URL("history/incoming", anastasisBaseUrl); + let response = await axios.get(url.href, { params: params, + auth: { + username: username, + password: password, + }, + }); + return response; + } + + // FIXME: this function should return some structured + // object that represents a history. + export async function getAccountTransactions( + libeufinNexusService: LibeufinNexusServiceInterface, + accountName: string, + username: string = "admin", + password: string = "test", + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/bank-accounts/${accountName}/transactions`, baseUrl); + let response = await axios.get(url.href, { + auth: { + username: username, + password: password, + }, + }); + return response; + } + + export async function fetchTransactions( + libeufinNexusService: LibeufinNexusServiceInterface, + accountName: string, + rangeType: string = "all", + level: string = "report", + username: string = "admin", + password: string = "test", + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `/bank-accounts/${accountName}/fetch-transactions`, + baseUrl, + ); + return await axios.post( + url.href, + { + rangeType: rangeType, + level: level, + }, + { + auth: { + username: username, + password: password, + }, + }, + ); + } + + export async function changePassword( + libeufinNexusService: LibeufinNexusServiceInterface, + username: string, + req: UpdateNexusUserRequest, + auth: NexusAuth, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/users/${username}/password`, baseUrl); + await axios.post(url.href, req, auth); + } + + export async function getUser( + libeufinNexusService: LibeufinNexusServiceInterface, + auth: NexusAuth, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/user`, baseUrl); + return await axios.get(url.href, auth); + } + + export async function createUser( + libeufinNexusService: LibeufinNexusServiceInterface, + req: CreateNexusUserRequest, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/users`, baseUrl); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function getAllPermissions( + libeufinNexusService: LibeufinNexusServiceInterface, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/permissions`, baseUrl); + return await axios.get(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function postPermission( + libeufinNexusService: LibeufinNexusServiceInterface, + req: PostNexusPermissionRequest, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/permissions`, baseUrl); + await axios.post(url.href, req, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function getTasks( + libeufinNexusService: LibeufinNexusServiceInterface, + bankAccountName: string, + // When void, the request returns the list of all the + // tasks under this bank account. + taskName: string | void, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl); + if (taskName) url = new URL(taskName, `${url}/`); + + // It's caller's responsibility to interpret the response. + return await axios.get(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function deleteTask( + libeufinNexusService: LibeufinNexusServiceInterface, + bankAccountName: string, + taskName: string, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `/bank-accounts/${bankAccountName}/schedule/${taskName}`, + baseUrl, + ); + await axios.delete(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function postTask( + libeufinNexusService: LibeufinNexusServiceInterface, + bankAccountName: string, + req: PostNexusTaskRequest, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl); + return await axios.post(url.href, req, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function deleteFacade( + libeufinNexusService: LibeufinNexusServiceInterface, + facadeName: string, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL(`facades/${facadeName}`, baseUrl); + return await axios.delete(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function getAllFacades( + libeufinNexusService: LibeufinNexusServiceInterface, + ): Promise<any> { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL("facades", baseUrl); + return await axios.get(url.href, { + auth: { + username: "admin", + password: "test", + }, + }); + } + + export async function createAnastasisFacade( + libeufinNexusService: LibeufinNexusServiceInterface, + req: CreateAnastasisFacadeRequest, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL("facades", baseUrl); + await axios.post( + url.href, + { + name: req.name, + type: "anastasis", + config: { + bankAccount: req.accountName, + bankConnection: req.connectionName, + currency: req.currency, + reserveTransferLevel: req.reserveTransferLevel, + }, + }, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function createTwgFacade( + libeufinNexusService: LibeufinNexusServiceInterface, + req: CreateTalerWireGatewayFacadeRequest, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL("facades", baseUrl); + await axios.post( + url.href, + { + name: req.name, + type: "taler-wire-gateway", + config: { + bankAccount: req.accountName, + bankConnection: req.connectionName, + currency: req.currency, + reserveTransferLevel: req.reserveTransferLevel, + }, + }, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } + + export async function submitAllPaymentInitiations( + libeufinNexusService: LibeufinNexusServiceInterface, + accountId: string, + ) { + const baseUrl = libeufinNexusService.baseUrl; + let url = new URL( + `/bank-accounts/${accountId}/submit-all-payment-initiations`, + baseUrl, + ); + await axios.post( + url.href, + {}, + { + auth: { + username: "admin", + password: "test", + }, + }, + ); + } +} + diff --git a/packages/taler-wallet-cli/src/harness/libeufin.ts b/packages/taler-wallet-cli/src/harness/libeufin.ts index ea4a2685a..d101efa52 100644 --- a/packages/taler-wallet-cli/src/harness/libeufin.ts +++ b/packages/taler-wallet-cli/src/harness/libeufin.ts @@ -15,11 +15,19 @@ */ /** + * This file defines euFin test logic that needs state + * and that depends on the main harness.ts. The other + * definitions - mainly helper functions to call RESTful + * APIs - moved to libeufin-apis.ts. That enables harness.ts + * to depend on such API calls, in contrast to the previous + * situation where harness.ts had to include this file causing + * a circular dependency. */ + +/** * Imports. */ import axios from "axios"; import { URL } from "@gnu-taler/taler-util"; -import { getRandomIban, getRandomString } from "../harness/helpers.js"; import { GlobalTestState, DbInfo, @@ -30,12 +38,27 @@ import { sh, } from "../harness/harness.js"; -export interface LibeufinSandboxServiceInterface { - baseUrl: string; -} - -export interface LibeufinNexusServiceInterface { - baseUrl: string; +import { + LibeufinSandboxApi, + LibeufinNexusApi, + CreateEbicsBankAccountRequest, + LibeufinSandboxServiceInterface, + CreateTalerWireGatewayFacadeRequest, + SimulateIncomingTransactionRequest, + SandboxAccountTransactions, + DeleteBankConnectionRequest, + CreateEbicsBankConnectionRequest, + UpdateNexusUserRequest, + NexusAuth, + CreateAnastasisFacadeRequest, + PostNexusTaskRequest, + PostNexusPermissionRequest, + CreateNexusUserRequest +} from "../harness/libeufin-apis.js"; + +export { + LibeufinSandboxApi, + LibeufinNexusApi } export interface LibeufinServices { @@ -54,10 +77,6 @@ export interface LibeufinNexusConfig { databaseJdbcUri: string; } -export interface DeleteBankConnectionRequest { - bankConnectionId: string; -} - interface LibeufinNexusMoneyMovement { amount: string; creditDebitIndicator: string; @@ -154,13 +173,6 @@ export interface LibeufinBankAccountImportDetails { connectionName: string; } -export interface BankAccountInfo { - iban: string; - bic: string; - name: string; - label: string; -} - export interface LibeufinPreparedPaymentDetails { creditorIban: string; creditorBic: string; @@ -171,18 +183,8 @@ export interface LibeufinPreparedPaymentDetails { nexusBankAccountName: string; } -export interface LibeufinSandboxAddIncomingRequest { - creditorIban: string; - creditorBic: string; - creditorName: string; - debtorIban: string; - debtorBic: string; - debtorName: string; - subject: string; - amount: string; - currency: string; - uid: string; - direction: string; +function getRandomIban(countryCode: string): string { + return `${countryCode}715001051796${(Math.random().toString().substring(2, 8))}` } export class LibeufinSandboxService implements LibeufinSandboxServiceInterface { @@ -317,51 +319,12 @@ export class LibeufinNexusService { } } -export interface CreateEbicsSubscriberRequest { - hostID: string; - userID: string; - partnerID: string; - systemID?: string; -} - export interface TwgAddIncomingRequest { amount: string; reserve_pub: string; debit_account: string; } -interface CreateEbicsBankAccountRequest { - subscriber: { - hostID: string; - partnerID: string; - userID: string; - systemID?: string; - }; - // IBAN - iban: string; - // BIC - bic: string; - // human name - name: string; - label: string; -} - -export interface SimulateIncomingTransactionRequest { - debtorIban: string; - debtorBic: string; - debtorName: string; - - /** - * Subject / unstructured remittance info. - */ - subject: string; - - /** - * Decimal amount without currency. - */ - amount: string; -} - /** * The bundle aims at minimizing the amount of input * data that is required to initialize a new user + Ebics @@ -756,7 +719,6 @@ export class LibeufinCli { console.log(stdout); } - async newTalerWireGatewayFacade(req: NewTalerWireGatewayReq): Promise<void> { const stdout = await sh( this.globalTestState, @@ -805,752 +767,6 @@ interface NewTalerWireGatewayReq { currency: string; } -export namespace LibeufinSandboxApi { - - export async function rotateKeys( - libeufinSandboxService: LibeufinSandboxServiceInterface, - hostID: string, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL(`admin/ebics/hosts/${hostID}/rotate-keys`, baseUrl); - await axios.post(url.href, {}, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - export async function createEbicsHost( - libeufinSandboxService: LibeufinSandboxServiceInterface, - hostID: string, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL("admin/ebics/hosts", baseUrl); - await axios.post(url.href, { - hostID, - ebicsVersion: "2.5", - }, - { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function createBankAccount( - libeufinSandboxService: LibeufinSandboxServiceInterface, - req: BankAccountInfo, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL(`admin/bank-accounts/${req.label}`, baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function createEbicsSubscriber( - libeufinSandboxService: LibeufinSandboxServiceInterface, - req: CreateEbicsSubscriberRequest, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL("admin/ebics/subscribers", baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function createEbicsBankAccount( - libeufinSandboxService: LibeufinSandboxServiceInterface, - req: CreateEbicsBankAccountRequest, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL("admin/ebics/bank-accounts", baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function bookPayment2( - libeufinSandboxService: LibeufinSandboxService, - req: LibeufinSandboxAddIncomingRequest, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL("admin/payments", baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function bookPayment( - libeufinSandboxService: LibeufinSandboxService, - creditorBundle: SandboxUserBundle, - debitorBundle: SandboxUserBundle, - subject: string, - amount: string, - currency: string, - ) { - let req: LibeufinSandboxAddIncomingRequest = { - creditorIban: creditorBundle.ebicsBankAccount.iban, - creditorBic: creditorBundle.ebicsBankAccount.bic, - creditorName: creditorBundle.ebicsBankAccount.name, - debtorIban: debitorBundle.ebicsBankAccount.iban, - debtorBic: debitorBundle.ebicsBankAccount.bic, - debtorName: debitorBundle.ebicsBankAccount.name, - subject: subject, - amount: amount, - currency: currency, - uid: getRandomString(), - direction: "CRDT", - }; - await bookPayment2(libeufinSandboxService, req); - } - - export async function simulateIncomingTransaction( - libeufinSandboxService: LibeufinSandboxServiceInterface, - accountLabel: string, - req: SimulateIncomingTransactionRequest, - ) { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL( - `admin/bank-accounts/${accountLabel}/simulate-incoming-transaction`, - baseUrl, - ); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function getAccountTransactions( - libeufinSandboxService: LibeufinSandboxServiceInterface, - accountLabel: string, - ): Promise<SandboxAccountTransactions> { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL( - `admin/bank-accounts/${accountLabel}/transactions`, - baseUrl, - ); - const res = await axios.get(url.href, { - auth: { - username: "admin", - password: "secret", - }, - }); - return res.data as SandboxAccountTransactions; - } - - export async function getCamt053( - libeufinSandboxService: LibeufinSandboxServiceInterface, - accountLabel: string, - ): Promise<any> { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL("admin/payments/camt", baseUrl); - return await axios.post(url.href, { - bankaccount: accountLabel, - type: 53, - }, - { - auth: { - username: "admin", - password: "secret", - }, - }); - } - - export async function getAccountInfoWithBalance( - libeufinSandboxService: LibeufinSandboxServiceInterface, - accountLabel: string, - ): Promise<any> { - const baseUrl = libeufinSandboxService.baseUrl; - let url = new URL( - `admin/bank-accounts/${accountLabel}`, - baseUrl, - ); - return await axios.get(url.href, { - auth: { - username: "admin", - password: "secret", - }, - }); - } -} - -export interface SandboxAccountTransactions { - payments: { - accountLabel: string; - creditorIban: string; - creditorBic?: string; - creditorName: string; - debtorIban: string; - debtorBic: string; - debtorName: string; - amount: string; - currency: string; - subject: string; - date: string; - creditDebitIndicator: "debit" | "credit"; - accountServicerReference: string; - }[]; -} - -export interface CreateEbicsBankConnectionRequest { - name: string; - ebicsURL: string; - hostID: string; - userID: string; - partnerID: string; - systemID?: string; -} - -export interface CreateAnastasisFacadeRequest { - name: string; - connectionName: string; - accountName: string; - currency: string; - reserveTransferLevel: "report" | "statement" | "notification"; -} - - -export interface CreateTalerWireGatewayFacadeRequest { - name: string; - connectionName: string; - accountName: string; - currency: string; - reserveTransferLevel: "report" | "statement" | "notification"; -} - -export interface UpdateNexusUserRequest { - newPassword: string; -} - -export interface NexusAuth { - auth: { - username: string; - password: string; - }; -} - -export interface CreateNexusUserRequest { - username: string; - password: string; -} - -export interface PostNexusTaskRequest { - name: string; - cronspec: string; - type: string; // fetch | submit - params: - | { - level: string; // report | statement | all - rangeType: string; // all | since-last | previous-days | latest - } - | {}; -} - -export interface PostNexusPermissionRequest { - action: "revoke" | "grant"; - permission: { - subjectType: string; - subjectId: string; - resourceType: string; - resourceId: string; - permissionName: string; - }; -} - -export namespace LibeufinNexusApi { - export async function getAllConnections( - nexus: LibeufinNexusServiceInterface, - ): Promise<any> { - let url = new URL("bank-connections", nexus.baseUrl); - const res = await axios.get(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - return res; - } - - export async function deleteBankConnection( - libeufinNexusService: LibeufinNexusServiceInterface, - req: DeleteBankConnectionRequest, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL("bank-connections/delete-connection", baseUrl); - return await axios.post(url.href, req, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function createEbicsBankConnection( - libeufinNexusService: LibeufinNexusServiceInterface, - req: CreateEbicsBankConnectionRequest, - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL("bank-connections", baseUrl); - await axios.post( - url.href, - { - source: "new", - type: "ebics", - name: req.name, - data: { - ebicsURL: req.ebicsURL, - hostID: req.hostID, - userID: req.userID, - partnerID: req.partnerID, - systemID: req.systemID, - }, - }, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function getBankAccount( - libeufinNexusService: LibeufinNexusServiceInterface, - accountName: string, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `bank-accounts/${accountName}`, - baseUrl, - ); - return await axios.get( - url.href, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - - export async function submitInitiatedPayment( - libeufinNexusService: LibeufinNexusServiceInterface, - accountName: string, - paymentId: string, - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `bank-accounts/${accountName}/payment-initiations/${paymentId}/submit`, - baseUrl, - ); - await axios.post( - url.href, - {}, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function fetchAccounts( - libeufinNexusService: LibeufinNexusServiceInterface, - connectionName: string, - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `bank-connections/${connectionName}/fetch-accounts`, - baseUrl, - ); - await axios.post( - url.href, - {}, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function importConnectionAccount( - libeufinNexusService: LibeufinNexusServiceInterface, - connectionName: string, - offeredAccountId: string, - nexusBankAccountId: string, - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `bank-connections/${connectionName}/import-account`, - baseUrl, - ); - await axios.post( - url.href, - { - offeredAccountId, - nexusBankAccountId, - }, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function connectBankConnection( - libeufinNexusService: LibeufinNexusServiceInterface, - connectionName: string, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`bank-connections/${connectionName}/connect`, baseUrl); - await axios.post( - url.href, - {}, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function getPaymentInitiations( - libeufinNexusService: LibeufinNexusService, - accountName: string, - username: string = "admin", - password: string = "test", - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `/bank-accounts/${accountName}/payment-initiations`, - baseUrl, - ); - let response = await axios.get(url.href, { - auth: { - username: username, - password: password, - }, - }); - console.log( - `Payment initiations of: ${accountName}`, - JSON.stringify(response.data, null, 2), - ); - } - - export async function getConfig( - libeufinNexusService: LibeufinNexusService, - ): Promise<void> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/config`, baseUrl); - let response = await axios.get(url.href); - } - - // Uses the Anastasis API to get a list of transactions. - export async function getAnastasisTransactions( - libeufinNexusService: LibeufinNexusService, - anastasisBaseUrl: string, - params: {}, // of the request: {delta: 5, ..} - username: string = "admin", - password: string = "test", - ): Promise<any> { - let url = new URL("history/incoming", anastasisBaseUrl); - let response = await axios.get(url.href, { params: params, - auth: { - username: username, - password: password, - }, - }); - return response; - } - - // FIXME: this function should return some structured - // object that represents a history. - export async function getAccountTransactions( - libeufinNexusService: LibeufinNexusService, - accountName: string, - username: string = "admin", - password: string = "test", - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/bank-accounts/${accountName}/transactions`, baseUrl); - let response = await axios.get(url.href, { - auth: { - username: username, - password: password, - }, - }); - return response; - } - - export async function fetchTransactions( - libeufinNexusService: LibeufinNexusService, - accountName: string, - rangeType: string = "all", - level: string = "report", - username: string = "admin", - password: string = "test", - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `/bank-accounts/${accountName}/fetch-transactions`, - baseUrl, - ); - return await axios.post( - url.href, - { - rangeType: rangeType, - level: level, - }, - { - auth: { - username: username, - password: password, - }, - }, - ); - } - - export async function changePassword( - libeufinNexusService: LibeufinNexusServiceInterface, - username: string, - req: UpdateNexusUserRequest, - auth: NexusAuth, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/users/${username}/password`, baseUrl); - await axios.post(url.href, req, auth); - } - - export async function getUser( - libeufinNexusService: LibeufinNexusServiceInterface, - auth: NexusAuth, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/user`, baseUrl); - return await axios.get(url.href, auth); - } - - export async function createUser( - libeufinNexusService: LibeufinNexusServiceInterface, - req: CreateNexusUserRequest, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/users`, baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function getAllPermissions( - libeufinNexusService: LibeufinNexusServiceInterface, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/permissions`, baseUrl); - return await axios.get(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function postPermission( - libeufinNexusService: LibeufinNexusServiceInterface, - req: PostNexusPermissionRequest, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/permissions`, baseUrl); - await axios.post(url.href, req, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function getTasks( - libeufinNexusService: LibeufinNexusServiceInterface, - bankAccountName: string, - // When void, the request returns the list of all the - // tasks under this bank account. - taskName: string | void, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl); - if (taskName) url = new URL(taskName, `${url}/`); - - // It's caller's responsibility to interpret the response. - return await axios.get(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function deleteTask( - libeufinNexusService: LibeufinNexusServiceInterface, - bankAccountName: string, - taskName: string, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `/bank-accounts/${bankAccountName}/schedule/${taskName}`, - baseUrl, - ); - await axios.delete(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function postTask( - libeufinNexusService: LibeufinNexusServiceInterface, - bankAccountName: string, - req: PostNexusTaskRequest, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`/bank-accounts/${bankAccountName}/schedule`, baseUrl); - return await axios.post(url.href, req, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function deleteFacade( - libeufinNexusService: LibeufinNexusServiceInterface, - facadeName: string, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL(`facades/${facadeName}`, baseUrl); - return await axios.delete(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function getAllFacades( - libeufinNexusService: LibeufinNexusServiceInterface, - ): Promise<any> { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL("facades", baseUrl); - return await axios.get(url.href, { - auth: { - username: "admin", - password: "test", - }, - }); - } - - export async function createAnastasisFacade( - libeufinNexusService: LibeufinNexusServiceInterface, - req: CreateAnastasisFacadeRequest, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL("facades", baseUrl); - await axios.post( - url.href, - { - name: req.name, - type: "anastasis", - config: { - bankAccount: req.accountName, - bankConnection: req.connectionName, - currency: req.currency, - reserveTransferLevel: req.reserveTransferLevel, - }, - }, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function createTwgFacade( - libeufinNexusService: LibeufinNexusServiceInterface, - req: CreateTalerWireGatewayFacadeRequest, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL("facades", baseUrl); - await axios.post( - url.href, - { - name: req.name, - type: "taler-wire-gateway", - config: { - bankAccount: req.accountName, - bankConnection: req.connectionName, - currency: req.currency, - reserveTransferLevel: req.reserveTransferLevel, - }, - }, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } - - export async function submitAllPaymentInitiations( - libeufinNexusService: LibeufinNexusServiceInterface, - accountId: string, - ) { - const baseUrl = libeufinNexusService.baseUrl; - let url = new URL( - `/bank-accounts/${accountId}/submit-all-payment-initiations`, - baseUrl, - ); - await axios.post( - url.href, - {}, - { - auth: { - username: "admin", - password: "test", - }, - }, - ); - } -} - /** * Launch Nexus and Sandbox AND creates users / facades / bank accounts / * .. all that's required to start making banking traffic. diff --git a/packages/taler-wallet-cli/src/index.ts b/packages/taler-wallet-cli/src/index.ts index 142e98e7c..71431b5eb 100644 --- a/packages/taler-wallet-cli/src/index.ts +++ b/packages/taler-wallet-cli/src/index.ts @@ -43,6 +43,8 @@ import { Configuration, decodeCrock, rsaBlind, + LogLevel, + setGlobalLogLevelFromString, } from "@gnu-taler/taler-util"; import { NodeHttpLib, @@ -161,6 +163,12 @@ export const walletCli = clk setDangerousTimetravel(x / 1000); }, }) + .maybeOption("log", ["-L", "--log"], clk.STRING, { + help: "configure log level (NONE, ..., TRACE)", + onPresentHandler: (x) => { + setGlobalLogLevelFromString(x); + }, + }) .maybeOption("inhibit", ["--inhibit"], clk.STRING, { help: "Inhibit running certain operations, useful for debugging and testing.", diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index 0d726a6d7..d8b344f2c 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -54,7 +54,7 @@ "po2json": "^0.4.5", "prettier": "^2.2.1", "rimraf": "^3.0.2", - "rollup": "^2.37.1", + "rollup": "^2.38", "rollup-plugin-sourcemaps": "^0.6.3", "source-map-resolve": "^0.6.0", "typedoc": "^0.20.16", diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index 913ffcb2e..3f4c02274 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -40,6 +40,7 @@ import { ConfirmPayResultType, durationFromSpec, getTimestampNow, + HttpStatusCode, j2s, Logger, notEmpty, @@ -84,7 +85,6 @@ import { } from "../../db.js"; import { guardOperationException } from "../../errors.js"; import { - HttpResponseStatus, readSuccessResponseJsonOrThrow, readTalerErrorResponse, } from "../../util/http.js"; @@ -317,7 +317,7 @@ async function runBackupCycleForProvider( logger.trace(`sync response status: ${resp.status}`); - if (resp.status === HttpResponseStatus.NotModified) { + if (resp.status === HttpStatusCode.NotModified) { await ws.db .mktx((x) => ({ backupProvider: x.backupProviders })) .runReadWrite(async (tx) => { @@ -335,7 +335,7 @@ async function runBackupCycleForProvider( return; } - if (resp.status === HttpResponseStatus.PaymentRequired) { + if (resp.status === HttpStatusCode.PaymentRequired) { logger.trace("payment required for backup"); logger.trace(`headers: ${j2s(resp.headers)}`); const talerUri = resp.headers.get("taler"); @@ -396,7 +396,7 @@ async function runBackupCycleForProvider( return; } - if (resp.status === HttpResponseStatus.NoContent) { + if (resp.status === HttpStatusCode.NoContent) { await ws.db .mktx((x) => ({ backupProviders: x.backupProviders })) .runReadWrite(async (tx) => { @@ -415,7 +415,7 @@ async function runBackupCycleForProvider( return; } - if (resp.status === HttpResponseStatus.Conflict) { + if (resp.status === HttpStatusCode.Conflict) { logger.info("conflicting backup found"); const backupEnc = new Uint8Array(await resp.bytes()); const backupConfig = await provideBackupState(ws); diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index 8fad55994..a42480f40 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -53,6 +53,7 @@ import { Logger, URL, getDurationRemaining, + HttpStatusCode, } from "@gnu-taler/taler-util"; import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util"; import { @@ -89,7 +90,6 @@ import { } from "../db.js"; import { getHttpResponseErrorDetails, - HttpResponseStatus, readSuccessResponseJsonOrErrorCode, readSuccessResponseJsonOrThrow, readTalerErrorResponse, @@ -1222,7 +1222,7 @@ async function submitPay( }; } - if (resp.status === HttpResponseStatus.BadRequest) { + if (resp.status === HttpStatusCode.BadRequest) { const errDetails = await readUnexpectedResponseDetails(resp); logger.warn("unexpected 400 response for /pay"); logger.warn(j2s(errDetails)); @@ -1242,7 +1242,7 @@ async function submitPay( throw new OperationFailedAndReportedError(errDetails); } - if (resp.status === HttpResponseStatus.Conflict) { + if (resp.status === HttpStatusCode.Conflict) { const err = await readTalerErrorResponse(resp); if ( err.code === diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index 144514e1c..d727bd06f 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { encodeCrock, getRandomBytes } from "@gnu-taler/taler-util"; +import { encodeCrock, getRandomBytes, HttpStatusCode } from "@gnu-taler/taler-util"; import { CoinRecord, CoinSourceType, @@ -40,7 +40,6 @@ import { import { AmountJson, Amounts } from "@gnu-taler/taler-util"; import { amountToPretty } from "@gnu-taler/taler-util"; import { - HttpResponseStatus, readSuccessResponseJsonOrThrow, readUnexpectedResponseDetails, } from "../util/http.js"; @@ -377,7 +376,7 @@ async function refreshMelt( }); }); - if (resp.status === HttpResponseStatus.NotFound) { + if (resp.status === HttpStatusCode.NotFound) { const errDetails = await readUnexpectedResponseDetails(resp); await ws.db .mktx((x) => ({ diff --git a/packages/taler-wallet-core/src/util/http.ts b/packages/taler-wallet-core/src/util/http.ts index d01f2ee42..0556d2274 100644 --- a/packages/taler-wallet-core/src/util/http.ts +++ b/packages/taler-wallet-core/src/util/http.ts @@ -59,17 +59,6 @@ export interface HttpRequestOptions { body?: string | ArrayBuffer | ArrayBufferView; } -export enum HttpResponseStatus { - Ok = 200, - NoContent = 204, - Gone = 210, - NotModified = 304, - BadRequest = 400, - PaymentRequired = 402, - NotFound = 404, - Conflict = 409, -} - /** * Headers, roughly modeled after the fetch API's headers object. */ diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 4023e4ebd..3a43f1e76 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -45,7 +45,7 @@ "@storybook/preact": "^6.2.9", "@testing-library/preact": "^2.0.1", "@types/chrome": "^0.0.128", - "@types/enzyme": "^3.10.8", + "@types/enzyme": "^3.10.10", "@types/history": "^4.7.8", "@types/jest": "^26.0.23", "@types/node": "^14.14.22", @@ -80,4 +80,4 @@ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|po)$": "<rootDir>/tests/__mocks__/fileTransformer.js" } } -}
\ No newline at end of file +} diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index 8a97ad50c..cf41efb59 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -14,17 +14,17 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountJson, AmountLike, Amounts, i18n, Transaction, TransactionType } from "@gnu-taler/taler-util"; +import { AmountLike, Amounts, i18n, Transaction, TransactionType } from "@gnu-taler/taler-util"; import { format } from "date-fns"; -import { Fragment, JSX, VNode, h } from "preact"; +import { JSX, VNode } from "preact"; import { route } from 'preact-router'; import { useEffect, useState } from "preact/hooks"; -import * as wxApi from "../wxApi"; -import { Pages } from "../NavigationBar"; -import emptyImg from "../../static/img/empty.png" -import { Button, ButtonBox, ButtonBoxDestructive, ButtonDestructive, ButtonPrimary, ExtraLargeText, FontIcon, LargeText, ListOfProducts, PopupBox, Row, RowBorderGray, SmallLightText, WalletBox, WarningBox } from "../components/styled"; +import emptyImg from "../../static/img/empty.png"; import { ErrorMessage } from "../components/ErrorMessage"; import { Part } from "../components/Part"; +import { ButtonBox, ButtonBoxDestructive, ButtonPrimary, FontIcon, ListOfProducts, RowBorderGray, SmallLightText, WalletBox, WarningBox } from "../components/styled"; +import { Pages } from "../NavigationBar"; +import * as wxApi from "../wxApi"; export function TransactionPage({ tid }: { tid: string; }): JSX.Element { const [transaction, setTransaction] = useState< @@ -42,7 +42,7 @@ export function TransactionPage({ tid }: { tid: string; }): JSX.Element { } }; fetchData(); - }, []); + }, [tid]); if (!transaction) { return <div><i18n.Translate>Loading ...</i18n.Translate></div>; |