added some logging messages

This commit is contained in:
Boss Marco 2021-11-05 16:57:32 +01:00
commit 98064f0652
No known key found for this signature in database
GPG Key ID: 89A3EC33C625C3DF
124 changed files with 14522 additions and 4463 deletions

View File

@ -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"
}
}

View 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();

View File

@ -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": [

View 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];

View 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;
}

View 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();

View 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();
}

View File

@ -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,

View File

@ -0,0 +1,2 @@
export * from "./index.js";
export { reducerCliMain } from "./cli.js";

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@ -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;
};
}

View File

@ -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
@ -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.

View File

@ -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";
@ -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");

View 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)
}

View File

@ -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 = {

View File

@ -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",

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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>;
}

View 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>
}

View 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>;
}

View 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>
;
}

View File

@ -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>
);
}

View 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>
}

View 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 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>
}

View File

@ -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>
);
}

View 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>
);
}

View File

@ -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>

View File

@ -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>)}

View 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);
}

View File

@ -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 />
}

View File

@ -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}`;
}

View File

@ -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;
}
}

View 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
};
}

View File

@ -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();
},
};
}

View File

@ -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' });

View 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>
);
}

View File

@ -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);

View File

@ -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
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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',

View File

@ -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>);
}

View File

@ -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,
);

View File

@ -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>
);
})}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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});

View 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=""> &lt;&lt; off &gt;&gt; </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>
}

View File

@ -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' },

View File

@ -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.

View File

@ -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);

View File

@ -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>
);
}

View File

@ -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);

View File

@ -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>
);

View File

@ -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' },

View File

@ -4,9 +4,10 @@ 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()
@ -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&nbsp;
<FileInput
label="click here"
bind={[secretValue, setSecretValue]}
/>
&nbsp;to import a file
</div>
</div>
</AnastasisClientFrame>
);

View File

@ -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,
},

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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'
}],

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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' },

View File

@ -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>
);

View File

@ -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' },

View File

@ -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

View File

@ -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
}]
});

View File

@ -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>
);
}

View File

@ -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
}]
},);

View File

@ -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>
);
}

View File

@ -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
}]
});

View File

@ -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>
);
}

View File

@ -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
}]
});

View File

@ -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 >
);
}

View File

@ -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
}]
});

View File

@ -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>
);
}

View File

@ -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
}]
});

View File

@ -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>
);
}

View File

@ -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
}]
});

View File

@ -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>
);
}

View 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,
}
}

View 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))

View File

@ -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
}]} />
);
}

Some files were not shown because too many files have changed in this diff Show More