added some logging messages
@ -8,8 +8,8 @@
|
||||
"check": "pnpm run --filter '{packages}' --if-present test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@linaria/esbuild": "^3.0.0-beta.7",
|
||||
"@linaria/shaker": "^3.0.0-beta.7",
|
||||
"esbuild": "^0.12.21"
|
||||
"@linaria/esbuild": "^3.0.0-beta.13",
|
||||
"@linaria/shaker": "^3.0.0-beta.13",
|
||||
"esbuild": "^0.12.29"
|
||||
}
|
||||
}
|
||||
|
14
packages/anastasis-core/bin/anastasis-ts-reducer.js
Executable file
@ -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();
|
@ -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": [
|
||||
|
56
packages/anastasis-core/rollup.config.js
Normal file
@ -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];
|
168
packages/anastasis-core/src/challenge-feedback-types.ts
Normal file
@ -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;
|
||||
}
|
15
packages/anastasis-core/src/cli-entry.ts
Normal file
@ -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();
|
64
packages/anastasis-core/src/cli.ts
Normal file
@ -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();
|
||||
}
|
@ -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,
|
||||
|
2
packages/anastasis-core/src/index.node.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from "./index.js";
|
||||
export { reducerCliMain } from "./cli.js";
|
230
packages/anastasis-core/src/policy-suggestion.ts
Normal file
@ -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;
|
||||
}
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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");
|
||||
|
28
packages/anastasis-core/src/validators.ts
Normal file
@ -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)
|
||||
}
|
||||
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
BIN
packages/anastasis-webui/src/assets/empty.png
Normal file
After Width: | Height: | Size: 2.7 KiB |
BIN
packages/anastasis-webui/src/assets/example/id1.jpg
Normal file
After Width: | Height: | Size: 101 KiB |
@ -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>
|
After Width: | Height: | Size: 274 B |
@ -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>
|
After Width: | Height: | Size: 359 B |
@ -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>
|
After Width: | Height: | Size: 483 B |
@ -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>
|
After Width: | Height: | Size: 272 B |
@ -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>
|
After Width: | Height: | Size: 525 B |
49
packages/anastasis-webui/src/components/AsyncButton.tsx
Normal file
@ -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>;
|
||||
}
|
59
packages/anastasis-webui/src/components/Notifications.tsx
Normal file
@ -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>
|
||||
}
|
35
packages/anastasis-webui/src/components/QR.tsx
Normal file
@ -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>;
|
||||
}
|
74
packages/anastasis-webui/src/components/fields/DateInput.tsx
Normal file
@ -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>
|
||||
;
|
||||
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
81
packages/anastasis-webui/src/components/fields/FileInput.tsx
Normal file
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
42
packages/anastasis-webui/src/components/fields/TextInput.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
|
@ -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>
|
||||
<span class="menu-item-label"><Translate>Secret selection</Translate></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class={reducer.currentReducerState.recovery_state === RecoveryStates.ChallengeSelecting ? '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>ChallengeSelecting</Translate></span>
|
||||
</div>
|
||||
</li>
|
||||
<li class={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>)}
|
||||
|
326
packages/anastasis-webui/src/components/picker/DatePicker.tsx
Normal file
@ -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);
|
||||
}
|
@ -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 />
|
||||
}
|
@ -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}`;
|
||||
}
|
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
77
packages/anastasis-webui/src/hooks/async.ts
Normal file
@ -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
|
||||
};
|
||||
}
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -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' });
|
101
packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx
Normal file
@ -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>
|
||||
);
|
||||
}
|
@ -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: 'first name',
|
||||
label: 'first',
|
||||
type: 'type',
|
||||
type: 'string',
|
||||
uuid: 'asdasdsa1',
|
||||
widget: 'wid',
|
||||
}, {
|
||||
name: 'pepe',
|
||||
name: 'last name',
|
||||
label: 'second',
|
||||
type: 'type',
|
||||
type: 'string',
|
||||
uuid: 'asdasdsa2',
|
||||
widget: 'wid',
|
||||
}, {
|
||||
name: 'pepe2',
|
||||
label: 'third',
|
||||
type: 'type',
|
||||
name: 'birthdate',
|
||||
label: 'birthdate',
|
||||
type: 'date',
|
||||
uuid: 'asdasdsa3',
|
||||
widget: 'calendar',
|
||||
}]
|
||||
} as ReducerState);
|
||||
|
||||
export const Empty = createExample(TestedComponent, {
|
||||
...reducerStatesExample.attributeEditing,
|
||||
export const Recovery = createExample(TestedComponent, {
|
||||
...reducerStatesExample.recoveryAttributeEditing,
|
||||
required_attributes: [{
|
||||
name: 'first',
|
||||
label: 'first',
|
||||
type: 'string',
|
||||
uuid: 'asdasdsa1',
|
||||
widget: 'wid',
|
||||
}, {
|
||||
name: 'pepe',
|
||||
label: 'second',
|
||||
type: 'string',
|
||||
uuid: 'asdasdsa2',
|
||||
widget: 'wid',
|
||||
}, {
|
||||
name: 'pepe2',
|
||||
label: 'third',
|
||||
type: 'date',
|
||||
uuid: 'asdasdsa3',
|
||||
widget: 'calendar',
|
||||
}]
|
||||
} as ReducerState);
|
||||
|
||||
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);
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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;
|
||||
|
@ -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} />
|
||||
);
|
||||
}
|
||||
function MethodButton(props: { method: string; label: string }): VNode {
|
||||
return (
|
||||
<button
|
||||
disabled={!authAvailableSet.has(props.method)}
|
||||
onClick={() => {
|
||||
setSelectedMethod(props.method);
|
||||
if (reducer) reducer.dismissError();
|
||||
|
||||
{!authAvailableSet.has(selectedMethod) && <ConfirmModal active
|
||||
onCancel={cancel} description="No providers founds" label="Add a provider manually"
|
||||
onConfirm={() => {
|
||||
null
|
||||
}}
|
||||
>
|
||||
{props.label}
|
||||
</button>
|
||||
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>
|
||||
);
|
||||
}
|
||||
const configuredAuthMethods: AuthMethod[] = reducer.currentReducerState.authentication_methods ?? [];
|
||||
const haveMethodsConfigured = configuredAuthMethods.length;
|
||||
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" />
|
||||
|
||||
if (addingProvider !== undefined) {
|
||||
return <div />
|
||||
}
|
||||
|
||||
function MethodButton(props: { method: KnownAuthMethods }): VNode {
|
||||
if (authMethods[props.method].skip) return <div />
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
const errors = !haveMethodsConfigured ? "There is not enough authentication methods." : undefined;
|
||||
return (
|
||||
<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;
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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
|
||||
successful.
|
||||
</p>
|
||||
<p>The backup is stored by the following providers:</p>
|
||||
|
||||
{details && <ul>
|
||||
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>
|
||||
Your secret was successfully backed up.
|
||||
</p>}
|
||||
|
||||
{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>);
|
||||
}
|
||||
|
@ -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',
|
||||
}]
|
||||
policies: [[{ uuid: "1" }]],
|
||||
challenges: [
|
||||
{
|
||||
cost: "USD:1",
|
||||
instructions: "just go for it",
|
||||
type: "question",
|
||||
uuid: "1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReducerState);
|
||||
|
||||
export const MoreChallenges = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting,
|
||||
export const SomePoliciesOneSolved = 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" }], [{ 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 OneBadConfiguredPolicy = createExample(TestedComponent, {...reducerStatesExample.challengeSelecting,
|
||||
export const OneBadConfiguredPolicy = 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" }]],
|
||||
challenges: [
|
||||
{
|
||||
cost: "USD:1",
|
||||
instructions: "this policy has a missing uuid (the other auth method)",
|
||||
type: "totp",
|
||||
uuid: "1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as ReducerState);
|
||||
|
||||
export const NoPolicies = createExample(TestedComponent, reducerStatesExample.challengeSelecting);
|
||||
export const OnePolicyWithAllTheChallenges = createExample(TestedComponent, {
|
||||
...reducerStatesExample.challengeSelecting,
|
||||
recovery_information: {
|
||||
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 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,
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
@ -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);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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});
|
||||
|
133
packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx
Normal file
@ -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>
|
||||
}
|
@ -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' },
|
||||
|
@ -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.
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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' },
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
},
|
||||
|
@ -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>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => selectVersion(otherProvider, 0)}>
|
||||
Use latest version
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<button onClick={() => setSelectingVersion(false)}>Cancel</button>
|
||||
</div>
|
||||
</AnastasisClientFrame>
|
||||
);
|
||||
return <SelectOtherVersionProviderScreen providers={providerList}
|
||||
provider={recoveryDocument.provider_url} version={recoveryDocument.version}
|
||||
onCancel={() => setSelectingVersion(false)}
|
||||
onConfirm={doSelectVersion}
|
||||
/>
|
||||
}
|
||||
|
||||
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>
|
||||
<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 class="column">
|
||||
<p>Secret found, you can select another version or continue to the challenges solving</p>
|
||||
</div>
|
||||
</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>
|
||||
</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 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>
|
||||
);
|
||||
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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'
|
||||
}],
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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' },
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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' },
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}]
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
},);
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
});
|
@ -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 >
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
@ -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
|
||||
}]
|
||||
});
|
@ -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>
|
||||
);
|
||||
}
|
69
packages/anastasis-webui/src/pages/home/authMethod/index.tsx
Normal file
@ -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,
|
||||
}
|
||||
}
|
56
packages/anastasis-webui/src/pages/home/authMethod/totp.ts
Normal file
@ -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))
|
@ -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
|
||||
}]} />
|
||||
);
|
||||
}
|
||||
|
||||
|