diff options
Diffstat (limited to 'packages')
260 files changed, 12795 insertions, 7153 deletions
diff --git a/packages/anastasis-core/src/anastasis-data.ts b/packages/anastasis-core/src/anastasis-data.ts index 4946e9dfd..c67883a2e 100644 --- a/packages/anastasis-core/src/anastasis-data.ts +++ b/packages/anastasis-core/src/anastasis-data.ts @@ -1,6 +1,7 @@ // This file is auto-generated, do not modify. // Generated from v0.2.0-4-g61ea83c on Tue, 05 Oct 2021 10:40:32 +0200 // To re-generate, run contrib/gen-ts.sh from the main anastasis code base. +// XXX: Modified for demo, allowing demo providers for EUR export const anastasisData = { providersList: { @@ -16,6 +17,22 @@ export const anastasisData = { currency: "KUDOS", }, { + url: "https://anastasis.demo.taler.net/", + currency: "EUR", + }, + { + url: "https://kudos.demo.anastasis.lu/", + currency: "EUR", + }, + { + url: "https://anastasis.demo.taler.net/", + currency: "CHF", + }, + { + url: "https://kudos.demo.anastasis.lu/", + currency: "CHF", + }, + { url: "http://localhost:8086/", currency: "TESTKUDOS", }, diff --git a/packages/anastasis-core/src/crypto.ts b/packages/anastasis-core/src/crypto.ts index 206d9eca8..75bd4b323 100644 --- a/packages/anastasis-core/src/crypto.ts +++ b/packages/anastasis-core/src/crypto.ts @@ -11,8 +11,6 @@ import { stringToBytes, secretbox_open, hash, - Logger, - j2s, } from "@gnu-taler/taler-util"; import { argon2id } from "hash-wasm"; diff --git a/packages/anastasis-core/src/index.ts b/packages/anastasis-core/src/index.ts index 362ac3317..15e1e5d97 100644 --- a/packages/anastasis-core/src/index.ts +++ b/packages/anastasis-core/src/index.ts @@ -65,6 +65,8 @@ import { ActionArgsChangeVersion, TruthMetaData, ActionArgsUpdatePolicy, + ActionArgsAddProvider, + ActionArgsDeleteProvider, } from "./reducer-types.js"; import fetchPonyfill from "fetch-ponyfill"; import { @@ -109,13 +111,23 @@ export * from "./challenge-feedback-types.js"; const logger = new Logger("anastasis-core:index.ts"); -function getContinents(): ContinentInfo[] { +function getContinents( + opts: { requireProvider?: boolean } = {}, +): ContinentInfo[] { + const currenciesWithProvider = new Set<string>(); + anastasisData.providersList.anastasis_provider.forEach((x) => { + currenciesWithProvider.add(x.currency); + }); const continentSet = new Set<string>(); const continents: ContinentInfo[] = []; for (const country of anastasisData.countriesList.countries) { if (continentSet.has(country.continent)) { continue; } + if (opts.requireProvider && !currenciesWithProvider.has(country.currency)) { + // Country's currency doesn't have any providers => skip + continue; + } continentSet.add(country.continent); continents.push({ ...{ name_i18n: country.continent_i18n }, @@ -148,9 +160,18 @@ export class ReducerError extends Error { * Get countries for a continent, abort with ReducerError * exception when continent doesn't exist. */ -function getCountries(continent: string): CountryInfo[] { +function getCountries( + continent: string, + opts: { requireProvider?: boolean } = {}, +): CountryInfo[] { + const currenciesWithProvider = new Set<string>(); + anastasisData.providersList.anastasis_provider.forEach((x) => { + currenciesWithProvider.add(x.currency); + }); const countries = anastasisData.countriesList.countries.filter( - (x) => x.continent === continent, + (x) => + x.continent === continent && + (!opts.requireProvider || currenciesWithProvider.has(x.currency)), ); if (countries.length <= 0) { throw new ReducerError({ @@ -164,14 +185,18 @@ function getCountries(continent: string): CountryInfo[] { export async function getBackupStartState(): Promise<ReducerStateBackup> { return { backup_state: BackupStates.ContinentSelecting, - continents: getContinents(), + continents: getContinents({ + requireProvider: true, + }), }; } export async function getRecoveryStartState(): Promise<ReducerStateRecovery> { return { recovery_state: RecoveryStates.ContinentSelecting, - continents: getContinents(), + continents: getContinents({ + requireProvider: true, + }), }; } @@ -952,6 +977,21 @@ async function requestTruth( } if (resp.status === HttpStatusCode.Forbidden) { + const body = await resp.json(); + if ( + body.code === TalerErrorCode.ANASTASIS_TRUTH_CHALLENGE_RESPONSE_REQUIRED + ) { + return { + ...state, + recovery_state: RecoveryStates.ChallengeSolving, + challenge_feedback: { + ...state.challenge_feedback, + [truth.uuid]: { + state: ChallengeFeedbackStatus.Pending, + }, + }, + }; + } return { ...state, recovery_state: RecoveryStates.ChallengeSolving, @@ -959,7 +999,7 @@ async function requestTruth( ...state.challenge_feedback, [truth.uuid]: { state: ChallengeFeedbackStatus.Message, - message: "Challenge should be solved", + message: body.hint ?? "Challenge should be solved", }, }, }; @@ -1022,9 +1062,15 @@ async function recoveryEnterUserAttributes( args: ActionArgsEnterUserAttributes, ): Promise<ReducerStateRecovery | ReducerStateError> { // FIXME: validate attributes + const providerUrls = Object.keys(state.authentication_providers ?? {}); + const newProviders = state.authentication_providers ?? {}; + for (const url of providerUrls) { + newProviders[url] = await getProviderInfo(url); + } const st: ReducerStateRecovery = { ...state, identity_attributes: args.identity_attributes, + authentication_providers: newProviders, }; return downloadPolicy(st); } @@ -1058,7 +1104,9 @@ async function backupSelectContinent( state: ReducerStateBackup, args: ActionArgsSelectContinent, ): Promise<ReducerStateBackup | ReducerStateError> { - const countries = getCountries(args.continent); + const countries = getCountries(args.continent, { + requireProvider: true, + }); if (countries.length <= 0) { return { code: TalerErrorCode.ANASTASIS_REDUCER_INPUT_INVALID, @@ -1077,7 +1125,9 @@ async function recoverySelectContinent( state: ReducerStateRecovery, args: ActionArgsSelectContinent, ): Promise<ReducerStateRecovery | ReducerStateError> { - const countries = getCountries(args.continent); + const countries = getCountries(args.continent, { + requireProvider: true, + }); return { ...state, recovery_state: RecoveryStates.CountrySelecting, @@ -1132,6 +1182,60 @@ function transitionRecoveryJump( }; } +//FIXME: doest the same that addProviderRecovery, but type are not generic enough +async function addProviderBackup( + state: ReducerStateBackup, + args: ActionArgsAddProvider, +): Promise<ReducerStateBackup> { + const info = await getProviderInfo(args.provider_url) + return { + ...state, + authentication_providers: { + ...(state.authentication_providers ?? {}), + [args.provider_url]: info, + }, + }; +} + +//FIXME: doest the same that deleteProviderRecovery, but type are not generic enough +async function deleteProviderBackup( + state: ReducerStateBackup, + args: ActionArgsDeleteProvider, +): Promise<ReducerStateBackup> { + const authentication_providers = {... state.authentication_providers ?? {} } + delete authentication_providers[args.provider_url] + return { + ...state, + authentication_providers, + }; +} + +async function addProviderRecovery( + state: ReducerStateRecovery, + args: ActionArgsAddProvider, +): Promise<ReducerStateRecovery> { + const info = await getProviderInfo(args.provider_url) + return { + ...state, + authentication_providers: { + ...(state.authentication_providers ?? {}), + [args.provider_url]: info, + }, + }; +} + +async function deleteProviderRecovery( + state: ReducerStateRecovery, + args: ActionArgsDeleteProvider, +): Promise<ReducerStateRecovery> { + const authentication_providers = {... state.authentication_providers ?? {} } + delete authentication_providers[args.provider_url] + return { + ...state, + authentication_providers, + }; +} + async function addAuthentication( state: ReducerStateBackup, args: ActionArgsAddAuthentication, @@ -1366,6 +1470,8 @@ const backupTransitions: Record< ...transitionBackupJump("back", BackupStates.UserAttributesCollecting), ...transition("add_authentication", codecForAny(), addAuthentication), ...transition("delete_authentication", codecForAny(), deleteAuthentication), + ...transition("add_provider", codecForAny(), addProviderBackup), + ...transition("delete_provider", codecForAny(), deleteProviderBackup), ...transition("next", codecForAny(), nextFromAuthenticationsEditing), }, [BackupStates.PoliciesReviewing]: { @@ -1434,6 +1540,8 @@ const recoveryTransitions: Record< [RecoveryStates.SecretSelecting]: { ...transitionRecoveryJump("back", RecoveryStates.UserAttributesCollecting), ...transitionRecoveryJump("next", RecoveryStates.ChallengeSelecting), + ...transition("add_provider", codecForAny(), addProviderRecovery), + ...transition("delete_provider", codecForAny(), deleteProviderRecovery), ...transition( "change_version", codecForActionArgsChangeVersion(), diff --git a/packages/anastasis-core/src/policy-suggestion.test.ts b/packages/anastasis-core/src/policy-suggestion.test.ts new file mode 100644 index 000000000..6370825da --- /dev/null +++ b/packages/anastasis-core/src/policy-suggestion.test.ts @@ -0,0 +1,44 @@ +import { j2s } from "@gnu-taler/taler-util"; +import test from "ava"; +import { ProviderInfo, suggestPolicies } from "./policy-suggestion.js"; + +test("policy suggestion", async (t) => { + const methods = [ + { + challenge: "XXX", + instructions: "SMS to 123", + type: "sms", + }, + { + challenge: "XXX", + instructions: "What is the meaning of life?", + type: "question", + }, + { + challenge: "XXX", + instructions: "email to foo@bar.com", + type: "email", + }, + ]; + const providers: ProviderInfo[] = [ + { + methodCost: { + sms: "KUDOS:1", + }, + url: "prov1", + }, + { + methodCost: { + question: "KUDOS:1", + }, + url: "prov2", + }, + ]; + const res1 = suggestPolicies(methods, providers); + t.assert(res1.policies.length === 1); + const res2 = suggestPolicies([...methods].reverse(), providers); + t.assert(res2.policies.length === 1); + + const res3 = suggestPolicies(methods, [...providers].reverse()); + t.assert(res3.policies.length === 1); +}); diff --git a/packages/anastasis-core/src/policy-suggestion.ts b/packages/anastasis-core/src/policy-suggestion.ts index 7eb6c21cc..2c25caaa4 100644 --- a/packages/anastasis-core/src/policy-suggestion.ts +++ b/packages/anastasis-core/src/policy-suggestion.ts @@ -84,9 +84,16 @@ function assignProviders( 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]; + for (const methSelIndex in provSel) { + const provIndex = provSel[methSelIndex]; + if (typeof provIndex !== "number") { + throw Error("invariant failed"); + } + const methIndex = methodSelection[methSelIndex]; const meth = methods[methIndex]; + if (!meth) { + throw Error("invariant failed"); + } const prov = providers[provIndex]; if (!prov.methodCost[meth.type]) { possible = false; @@ -96,7 +103,6 @@ function assignProviders( if (!possible) { continue; } - // Evaluate diversity, always prefer policies // that increase diversity. const providerSet = new Set<string>(); @@ -163,10 +169,19 @@ function assignProviders( /** * A provider selection maps a method selection index to a provider index. + * + * I.e. "PSEL[i] = x" means that provider with index "x" should be used + * for method with index "MSEL[i]" */ type ProviderSelection = number[]; /** + * A method selection "MSEL[j] = y" means that policy method j + * should use method y. + */ +type MethodSelection = number[]; + +/** * Compute provider mappings. * Enumerates all n-combinations with repetition of m providers. */ @@ -184,7 +199,7 @@ function enumerateProviderMappings( } for (let j = start; j < m; j++) { a[i] = j; - sel(i + 1, j); + sel(i + 1, 0); if (limit && selections.length >= limit) { break; } @@ -199,8 +214,6 @@ interface PolicySelectionResult { policy_providers: PolicyProvider[]; } -type MethodSelection = number[]; - /** * Compute method selections. * Enumerates all n-combinations without repetition of m methods. diff --git a/packages/anastasis-core/src/reducer-types.ts b/packages/anastasis-core/src/reducer-types.ts index 0f64be4eb..3e6d6c852 100644 --- a/packages/anastasis-core/src/reducer-types.ts +++ b/packages/anastasis-core/src/reducer-types.ts @@ -50,6 +50,11 @@ export interface SuccessDetails { export interface CoreSecret { mime: string; value: string; + /** + * Filename, only set if the secret comes from + * a file. Should be set unless the mime type is "text/plain"; + */ + filename?: string; } export interface ReducerStateBackup { @@ -329,6 +334,14 @@ export const codecForActionArgsEnterUserAttributes = () => .property("identity_attributes", codecForAny()) .build("ActionArgsEnterUserAttributes"); +export interface ActionArgsAddProvider { + provider_url: string; +} + +export interface ActionArgsDeleteProvider { + provider_url: string; +} + export interface ActionArgsAddAuthentication { authentication_method: { type: string; diff --git a/packages/anastasis-webui/package.json b/packages/anastasis-webui/package.json index 96d2d65f9..d35b6ba27 100644 --- a/packages/anastasis-webui/package.json +++ b/packages/anastasis-webui/package.json @@ -4,12 +4,15 @@ "version": "0.0.0", "license": "MIT", "scripts": { - "build": "preact build --no-sw --no-esm", - "serve": "sirv build --port 8080 --cors --single", - "dev": "preact watch --no-sw --no-esm", + "build": "preact build --no-sw --no-esm --no-inline-css", + "serve": "sirv build --port ${PORT:=8080} --cors --single", + "dev": "preact watch --port ${PORT:=8080} --no-sw --no-esm", "lint": "eslint 'src/**/*.{js,jsx,ts,tsx}'", "test": "jest ./tests", "build-storybook": "build-storybook", + "build-single": "preact build --no-sw --no-esm -c preact.single-config.js --dest single && sh remove-link-stylesheet.sh", + "serve-single": "sirv single --port ${PORT:=8080} --cors --single", + "pretty": "prettier --write src", "storybook": "start-storybook -p 6006" }, "eslintConfig": { @@ -25,6 +28,7 @@ "dependencies": { "@gnu-taler/taler-util": "workspace:^0.8.3", "anastasis-core": "workspace:^0.0.1", + "base64-inline-loader": "1.1.1", "date-fns": "2.25.0", "jed": "1.1.1", "preact": "^10.5.15", @@ -67,4 +71,4 @@ "<rootDir>/tests/__mocks__/setupTests.ts" ] } -} +}
\ No newline at end of file diff --git a/packages/anastasis-webui/preact.config.js b/packages/anastasis-webui/preact.config.js new file mode 100644 index 000000000..8d6da1911 --- /dev/null +++ b/packages/anastasis-webui/preact.config.js @@ -0,0 +1,45 @@ +/* + 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 { DefinePlugin } from 'webpack'; + +import pack from './package.json'; +import * as cp from 'child_process'; + +const commitHash = cp.execSync('git rev-parse --short HEAD').toString(); + +export default { + webpack(config, env, helpers) { + // add __VERSION__ to be use in the html + config.plugins.push( + new DefinePlugin({ + 'process.env.__VERSION__': JSON.stringify(env.isProd ? pack.version : `dev-${commitHash}`), + }), + ); + const crittersWrapper = helpers.getPluginsByName(config, 'Critters') + if (crittersWrapper && crittersWrapper.length > 0) { + const [{ index }] = crittersWrapper + config.plugins.splice(index, 1) + } + + } +} + diff --git a/packages/anastasis-webui/src/.babelrc b/packages/anastasis-webui/src/.babelrc index 123002210..05f4dcc81 100644 --- a/packages/anastasis-webui/src/.babelrc +++ b/packages/anastasis-webui/src/.babelrc @@ -1,5 +1,3 @@ { - "presets": [ - "preact-cli/babel" - ] + "presets": ["preact-cli/babel"] } diff --git a/packages/anastasis-webui/src/components/AsyncButton.tsx b/packages/anastasis-webui/src/components/AsyncButton.tsx index 92bef2219..8f855f29f 100644 --- a/packages/anastasis-webui/src/components/AsyncButton.tsx +++ b/packages/anastasis-webui/src/components/AsyncButton.tsx @@ -15,11 +15,12 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { ComponentChildren, h, VNode } from "preact"; +import { useLayoutEffect, useRef } from "preact/hooks"; // import { LoadingModal } from "../modal"; import { useAsync } from "../hooks/async"; // import { Translate } from "../../i18n"; @@ -28,22 +29,38 @@ type Props = { children: ComponentChildren; disabled?: boolean; onClick?: () => Promise<void>; + grabFocus?: boolean; [rest: string]: any; }; -export function AsyncButton({ onClick, disabled, children, ...rest }: Props): VNode { +export function AsyncButton({ + onClick, + grabFocus, + disabled, + children, + ...rest +}: Props): VNode { const { isLoading, request } = useAsync(onClick); + const buttonRef = useRef<HTMLButtonElement>(null); + useLayoutEffect(() => { + if (grabFocus) { + buttonRef.current?.focus(); + } + }, [grabFocus]); + // if (isSlow) { // return <LoadingModal onCancel={cancel} />; // } - if (isLoading) { + 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>; + return ( + <span data-tooltip={rest["data-tooltip"]} style={{ marginLeft: 5 }}> + <button {...rest} ref={buttonRef} onClick={request} disabled={disabled}> + {children} + </button> + </span> + ); } diff --git a/packages/anastasis-webui/src/components/Notifications.tsx b/packages/anastasis-webui/src/components/Notifications.tsx index c916020d7..e34550386 100644 --- a/packages/anastasis-webui/src/components/Notifications.tsx +++ b/packages/anastasis-webui/src/components/Notifications.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; @@ -27,7 +27,7 @@ export interface Notification { type: MessageType; } -export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' +export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS"; interface Props { notifications: Notification[]; @@ -36,24 +36,39 @@ interface Props { 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" + case "INFO": + return "message is-info"; + case "WARN": + return "message is-warning"; + case "ERROR": + return "message is-danger"; + case "SUCCESS": + return "message is-success"; + default: + return "message"; } } -export function Notifications({ notifications, removeNotification }: Props): VNode { - return <div class="block"> - {notifications.map((n,i) => <article key={i} class={messageStyle(n.type)}> - <div class="message-header"> - <p>{n.message}</p> - <button class="delete" onClick={() => removeNotification && removeNotification(n)} /> - </div> - {n.description && <div class="message-body"> - {n.description} - </div>} - </article>)} - </div> -}
\ No newline at end of file +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> + {removeNotification && ( + <button + class="delete" + onClick={() => removeNotification && removeNotification(n)} + /> + )} + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + ))} + </div> + ); +} diff --git a/packages/anastasis-webui/src/components/QR.tsx b/packages/anastasis-webui/src/components/QR.tsx index 48f1a7c12..9a05f6097 100644 --- a/packages/anastasis-webui/src/components/QR.tsx +++ b/packages/anastasis-webui/src/components/QR.tsx @@ -21,15 +21,28 @@ import qrcode from "qrcode-generator"; export function QR({ text }: { text: string }): VNode { const divRef = useRef<HTMLDivElement>(null); useEffect(() => { - const qr = qrcode(0, 'L'); + const qr = qrcode(0, "L"); qr.addData(text); qr.make(); - if (divRef.current) divRef.current.innerHTML = qr.createSvgTag({ - scalable: true, - }); + 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>; + return ( + <div + style={{ + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + }} + > + <div + style={{ width: "50%", minWidth: 200, maxWidth: 300 }} + ref={divRef} + /> + </div> + ); } diff --git a/packages/anastasis-webui/src/components/app.tsx b/packages/anastasis-webui/src/components/app.tsx index c6b4cfc14..4c6683c0c 100644 --- a/packages/anastasis-webui/src/components/app.tsx +++ b/packages/anastasis-webui/src/components/app.tsx @@ -1,6 +1,5 @@ import { FunctionalComponent, h } from "preact"; import { TranslationProvider } from "../context/translation"; - import AnastasisClient from "../pages/home"; const App: FunctionalComponent = () => { diff --git a/packages/anastasis-webui/src/components/fields/DateInput.tsx b/packages/anastasis-webui/src/components/fields/DateInput.tsx index 3148c953f..18ef89908 100644 --- a/packages/anastasis-webui/src/components/fields/DateInput.tsx +++ b/packages/anastasis-webui/src/components/fields/DateInput.tsx @@ -1,4 +1,4 @@ -import { format, isAfter, parse, sub, subYears } from "date-fns"; +import { format, subYears } from "date-fns"; import { h, VNode } from "preact"; import { useLayoutEffect, useRef, useState } from "preact/hooks"; import { DatePicker } from "../picker/DatePicker"; @@ -9,6 +9,7 @@ export interface DateInputProps { tooltip?: string; error?: string; years?: Array<number>; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -19,56 +20,71 @@ export function DateInput(props: DateInputProps): VNode { inputRef.current?.focus(); } }, [props.grabFocus]); - const [opened, setOpened] = useState(false) + const [opened, setOpened] = useState(false); const value = props.bind[0] || ""; - const [dirty, setDirty] = useState(false) - const showError = dirty && props.error + 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> + 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} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + 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> - <p class="help">Using the format yyyy-mm-dd</p> - {showError && <p class="help is-danger">{props.error}</p>} - <DatePicker - opened={opened} - initialDate={calendar} - years={props.years} - closeFunction={() => setOpened(false)} - dateReceiver={(d) => { - setDirty(true) - const v = format(d, 'yyyy-MM-dd') - props.bind[1](v); - }} - /> - </div> - ; - + ); } diff --git a/packages/anastasis-webui/src/components/fields/EmailInput.tsx b/packages/anastasis-webui/src/components/fields/EmailInput.tsx index e21418fea..4c35c0686 100644 --- a/packages/anastasis-webui/src/components/fields/EmailInput.tsx +++ b/packages/anastasis-webui/src/components/fields/EmailInput.tsx @@ -7,6 +7,7 @@ export interface TextInputProps { error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -18,27 +19,39 @@ export function EmailInput(props: TextInputProps): VNode { } }, [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" }} /> + 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"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + 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> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/fields/FileInput.tsx b/packages/anastasis-webui/src/components/fields/FileInput.tsx index 8b144ea43..adf51afb0 100644 --- a/packages/anastasis-webui/src/components/fields/FileInput.tsx +++ b/packages/anastasis-webui/src/components/fields/FileInput.tsx @@ -15,16 +15,31 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @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 +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; + +export interface FileTypeContent { + content: string; + type: string; + name: string; +} + +export interface FileInputProps { + label: string; + grabFocus?: boolean; + disabled?: boolean; + error?: string; + placeholder?: string; + tooltip?: string; + onChange: (v: FileTypeContent | undefined) => void; +} -export function FileInput(props: TextInputProps): VNode { +export function FileInput(props: FileInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); useLayoutEffect(() => { if (props.grabFocus) { @@ -32,50 +47,58 @@ export function FileInput(props: TextInputProps): VNode { } }, [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>} + const fileInputRef = useRef<HTMLInputElement>(null); + const [sizeError, setSizeError] = useState(false); + return ( + <div class="field"> + <label class="label"> + <a class="button" onClick={(e) => fileInputRef.current?.click()}> + <div class="icon is-small "> + <i class="mdi mdi-folder" /> + </div> + <span> + {props.label} + </span> + </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={fileInputRef} + style={{ display: "none" }} + type="file" + // name={String(name)} + onChange={(e) => { + const f: FileList | null = e.currentTarget.files; + if (!f || f.length != 1) { + return props.onChange(undefined); + } + console.log(f) + if (f[0].size > MAX_IMAGE_UPLOAD_SIZE) { + setSizeError(true); + return props.onChange(undefined); + } + setSizeError(false); + return f[0].arrayBuffer().then((b) => { + const b64 = btoa( + new Uint8Array(b).reduce( + (data, byte) => data + String.fromCharCode(byte), + "", + ), + ); + return props.onChange({content: `data:${f[0].type};base64,${b64}`, name: f[0].name, type: f[0].type}); + }); + }} + /> + {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> - </div> + ); } - diff --git a/packages/anastasis-webui/src/components/fields/ImageInput.tsx b/packages/anastasis-webui/src/components/fields/ImageInput.tsx index d5bf643d4..3f8cc58dd 100644 --- a/packages/anastasis-webui/src/components/fields/ImageInput.tsx +++ b/packages/anastasis-webui/src/components/fields/ImageInput.tsx @@ -15,15 +15,15 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @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 +const MAX_IMAGE_UPLOAD_SIZE = 1024 * 1024; export function ImageInput(props: TextInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); @@ -35,47 +35,59 @@ export function ImageInput(props: TextInputProps): VNode { const value = props.bind[0]; // const [dirty, setDirty] = useState(false) - const image = useRef<HTMLInputElement>(null) - const [sizeError, setSizeError] = 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>} + 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> - </div> + ); } - diff --git a/packages/anastasis-webui/src/components/fields/NumberInput.tsx b/packages/anastasis-webui/src/components/fields/NumberInput.tsx index 2afb242b8..4856131c7 100644 --- a/packages/anastasis-webui/src/components/fields/NumberInput.tsx +++ b/packages/anastasis-webui/src/components/fields/NumberInput.tsx @@ -7,10 +7,11 @@ export interface TextInputProps { error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } -export function NumberInput(props: TextInputProps): VNode { +export function PhoneNumberInput(props: TextInputProps): VNode { const inputRef = useRef<HTMLInputElement>(null); useLayoutEffect(() => { if (props.grabFocus) { @@ -18,26 +19,38 @@ export function NumberInput(props: TextInputProps): VNode { } }, [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" }} /> + 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="tel" + placeholder={props.placeholder} + class={showError ? "input is-danger" : "input"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + 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> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/fields/TextInput.tsx b/packages/anastasis-webui/src/components/fields/TextInput.tsx index c093689c5..efa95d84e 100644 --- a/packages/anastasis-webui/src/components/fields/TextInput.tsx +++ b/packages/anastasis-webui/src/components/fields/TextInput.tsx @@ -4,9 +4,11 @@ import { useLayoutEffect, useRef, useState } from "preact/hooks"; export interface TextInputProps { label: string; grabFocus?: boolean; + disabled?: boolean; error?: string; placeholder?: string; tooltip?: string; + onConfirm?: () => void; bind: [string, (x: string) => void]; } @@ -18,25 +20,38 @@ export function TextInput(props: TextInputProps): VNode { } }, [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" }} /> + 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} + disabled={props.disabled} + placeholder={props.placeholder} + class={showError ? "input is-danger" : "input"} + onKeyPress={(e) => { + if (e.key === 'Enter' && props.onConfirm) { + props.onConfirm() + } + }} + 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> - {showError && <p class="help is-danger">{props.error}</p>} - </div> ); } diff --git a/packages/anastasis-webui/src/components/menu/LangSelector.tsx b/packages/anastasis-webui/src/components/menu/LangSelector.tsx index 0f91abd7e..fa22a29c0 100644 --- a/packages/anastasis-webui/src/components/menu/LangSelector.tsx +++ b/packages/anastasis-webui/src/components/menu/LangSelector.tsx @@ -15,59 +15,78 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import langIcon from '../../assets/icons/languageicon.svg'; +import langIcon from "../../assets/icons/languageicon.svg"; import { useTranslationContext } from "../../context/translation"; -import { strings as messages } from '../../i18n/strings' +import { strings as messages } from "../../i18n/strings"; type LangsNames = { - [P in keyof typeof messages]: string -} + [P in keyof typeof messages]: string; +}; const names: LangsNames = { - es: 'Español [es]', - en: 'English [en]', - fr: 'Français [fr]', - de: 'Deutsch [de]', - sv: 'Svenska [sv]', - it: 'Italiano [it]', -} + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; function getLangName(s: keyof LangsNames | string): string { - if (names[s]) return names[s] - return String(s) + if (names[s]) return names[s]; + return String(s); } export function LangSelector(): VNode { - const [updatingLang, setUpdatingLang] = useState(false) - const { lang, changeLanguage } = useTranslationContext() + const [updatingLang, setUpdatingLang] = useState(false); + const { lang, changeLanguage } = useTranslationContext(); - return <div class="dropdown is-active "> - <div class="dropdown-trigger"> - <button class="button has-tooltip-left" - data-tooltip="change language selection" - aria-haspopup="true" - aria-controls="dropdown-menu" onClick={() => setUpdatingLang(!updatingLang)}> - <div class="icon is-small is-left"> - <img src={langIcon} /> - </div> - <span>{getLangName(lang)}</span> - <div class="icon is-right"> - <i class="mdi mdi-chevron-down" /> + return ( + <div class="dropdown is-active "> + <div class="dropdown-trigger"> + <button + class="button has-tooltip-left" + data-tooltip="change language selection" + aria-haspopup="true" + aria-controls="dropdown-menu" + onClick={() => setUpdatingLang(!updatingLang)} + > + <div class="icon is-small is-left"> + <img src={langIcon} /> + </div> + <span>{getLangName(lang)}</span> + <div class="icon is-right"> + <i class="mdi mdi-chevron-down" /> + </div> + </button> + </div> + {updatingLang && ( + <div class="dropdown-menu" id="dropdown-menu" role="menu"> + <div class="dropdown-content"> + {Object.keys(messages) + .filter((l) => l !== lang) + .map((l) => ( + <a + key={l} + class="dropdown-item" + value={l} + onClick={() => { + changeLanguage(l); + setUpdatingLang(false); + }} + > + {getLangName(l)} + </a> + ))} + </div> </div> - </button> + )} </div> - {updatingLang && <div class="dropdown-menu" id="dropdown-menu" role="menu"> - <div class="dropdown-content"> - {Object.keys(messages) - .filter((l) => l !== lang) - .map(l => <a key={l} class="dropdown-item" value={l} onClick={() => { changeLanguage(l); setUpdatingLang(false) }}>{getLangName(l)}</a>)} - </div> - </div>} - </div> -}
\ No newline at end of file + ); +} diff --git a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx index 935951ab9..8d5a0473b 100644 --- a/packages/anastasis-webui/src/components/menu/NavigationBar.tsx +++ b/packages/anastasis-webui/src/components/menu/NavigationBar.tsx @@ -15,13 +15,13 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { h, VNode } from 'preact'; -import logo from '../../assets/logo.jpeg'; -import { LangSelector } from './LangSelector'; +import { h, VNode } from "preact"; +import logo from "../../assets/logo.jpeg"; +import { LangSelector } from "./LangSelector"; interface Props { onMobileMenu: () => void; @@ -29,30 +29,51 @@ interface Props { } export function NavigationBar({ onMobileMenu, title }: Props): VNode { - return (<nav class="navbar is-fixed-top" role="navigation" aria-label="main navigation"> - <div class="navbar-brand"> - <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}>{title}</span> - - <a role="button" class="navbar-burger" aria-label="menu" aria-expanded="false" onClick={(e) => { - onMobileMenu() - e.stopPropagation() - }}> - <span aria-hidden="true" /> - <span aria-hidden="true" /> - <span aria-hidden="true" /> - </a> - </div> - - <div class="navbar-menu "> - <a class="navbar-start is-justify-content-center is-flex-grow-1" href="https://taler.net"> - <img src={logo} style={{ height: 50, maxHeight: 50 }} /> - </a> - <div class="navbar-end"> - <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> - {/* <LangSelector /> */} + return ( + <nav + class="navbar is-fixed-top" + role="navigation" + aria-label="main navigation" + > + <div class="navbar-brand"> + <span class="navbar-item" style={{ fontSize: 24, fontWeight: 900 }}> + {title} + </span> + <a + href="mailto:contact@anastasis.lu" + style={{ alignSelf: "center", padding: "0.5em" }} + > + Contact us + </a> + <a + href="https://bugs.anastasis.li/" + style={{ alignSelf: "center", padding: "0.5em" }} + > + Report a bug + </a> + {/* <a + role="button" + class="navbar-burger" + aria-label="menu" + aria-expanded="false" + onClick={(e) => { + onMobileMenu(); + e.stopPropagation(); + }} + > + <span aria-hidden="true" /> + <span aria-hidden="true" /> + <span aria-hidden="true" /> + </a> */} + </div> + + <div class="navbar-menu "> + <div class="navbar-end"> + <div class="navbar-item" style={{ paddingTop: 4, paddingBottom: 4 }}> + {/* <LangSelector /> */} + </div> </div> </div> - </div> - </nav> + </nav> ); -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/components/menu/SideBar.tsx b/packages/anastasis-webui/src/components/menu/SideBar.tsx index 72655662f..c73369dd6 100644 --- a/packages/anastasis-webui/src/components/menu/SideBar.tsx +++ b/packages/anastasis-webui/src/components/menu/SideBar.tsx @@ -15,16 +15,15 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { Fragment, h, VNode } from 'preact'; -import { BackupStates, RecoveryStates } from '../../../../anastasis-core/lib'; -import { useAnastasisContext } from '../../context/anastasis'; -import { Translate } from '../../i18n'; -import { LangSelector } from './LangSelector'; +import { Fragment, h, VNode } from "preact"; +import { BackupStates, RecoveryStates } from "../../../../anastasis-core/lib"; +import { useAnastasisContext } from "../../context/anastasis"; +import { Translate } from "../../i18n"; +import { LangSelector } from "./LangSelector"; interface Props { mobile?: boolean; @@ -32,10 +31,10 @@ interface Props { export function Sidebar({ mobile }: Props): VNode { // const config = useConfigContext(); - const config = { version: 'none' } + const config = { version: "none" }; // FIXME: add replacement for __VERSION__ with the current version - const process = { env: { __VERSION__: '0.0.0' } } - const reducer = useAnastasisContext()! + const process = { env: { __VERSION__: "0.0.0" } }; + const reducer = useAnastasisContext()!; return ( <aside class="aside is-placed-left is-expanded"> @@ -44,114 +43,235 @@ export function Sidebar({ mobile }: Props): VNode { </div>} */} <div class="aside-tools"> <div class="aside-tools-label"> - <div><b>Anastasis</b> Reducer</div> - <div class="is-size-7 has-text-right" style={{ lineHeight: 0, marginTop: -10 }}> - {process.env.__VERSION__} ({config.version}) + <div> + <b>Anastasis</b> + </div> + <div + class="is-size-7 has-text-right" + style={{ lineHeight: 0, marginTop: -10 }} + > + Version {process.env.__VERSION__} ({config.version}) </div> </div> </div> <div class="menu is-menu-main"> - {!reducer.currentReducerState && + {!reducer.currentReducerState && ( <p class="menu-label"> <Translate>Backup or Recorver</Translate> </p> - } + )} <ul class="menu-list"> - {!reducer.currentReducerState && + {!reducer.currentReducerState && ( <li> <div class="ml-4"> - <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 || - reducer.currentReducerState.backup_state === BackupStates.CountrySelecting ? 'is-active' : ''}> - <div class="ml-4"> - <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>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>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>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>Secret input</Translate></span> + <span class="menu-item-label"> + <Translate>Select one option</Translate> + </span> </div> </li> - {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> + )} + {reducer.currentReducerState && + reducer.currentReducerState.backup_state ? ( + <Fragment> + <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>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>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>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>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>Secret input</Translate> + </span> + </div> + </li> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.PoliciesPaying ? 'is-active' : ''}> <div class="ml-4"> <span class="menu-item-label"><Translate>Payment (optional)</Translate></span> </div> </li> */} - <li class={reducer.currentReducerState.backup_state === BackupStates.BackupFinished ? 'is-active' : ''}> - <div class="ml-4"> - - <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.BackupFinished + ? "is-active" + : "" + } + > + <div class="ml-4"> + <span class="menu-item-label"> + <Translate>Backup completed</Translate> + </span> + </div> + </li> + {/* <li class={reducer.currentReducerState.backup_state === BackupStates.TruthsPaying ? 'is-active' : ''}> <div class="ml-4"> <span class="menu-item-label"><Translate>Truth Paying</Translate></span> </div> </li> */} - </Fragment> : (reducer.currentReducerState && reducer.currentReducerState?.recovery_state && <Fragment> - <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>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>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>Secret selection</Translate></span> - </div> - </li> - <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>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>Secret recovered</Translate></span> - </div> - </li> - </Fragment>)} - {reducer.currentReducerState && + </Fragment> + ) : ( + reducer.currentReducerState && + reducer.currentReducerState?.recovery_state && ( + <Fragment> + <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>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>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>Secret selection</Translate> + </span> + </div> + </li> + <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>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>Secret recovered</Translate> + </span> + </div> + </li> + </Fragment> + ) + )} + {reducer.currentReducerState && ( <li> <div class="buttons ml-4"> - <button class="button is-danger is-right" onClick={() => reducer.reset()}>Reset session</button> + <button + class="button is-danger is-right" + onClick={() => reducer.reset()} + > + Reset session + </button> </div> </li> - } - + )} + {/* <li> + <div class="buttons ml-4"> + <button class="button is-info is-right" >Manage providers</button> + </div> + </li> */} </ul> </div> </aside> ); } - diff --git a/packages/anastasis-webui/src/components/menu/index.tsx b/packages/anastasis-webui/src/components/menu/index.tsx index febcd79c8..99d0f7646 100644 --- a/packages/anastasis-webui/src/components/menu/index.tsx +++ b/packages/anastasis-webui/src/components/menu/index.tsx @@ -15,41 +15,53 @@ */ import { ComponentChildren, Fragment, h, VNode } from "preact"; -import Match from 'preact-router/match'; +import Match from "preact-router/match"; import { useEffect, useState } from "preact/hooks"; import { NavigationBar } from "./NavigationBar"; import { Sidebar } from "./SideBar"; - - - interface MenuProps { title: string; } -function WithTitle({ title, children }: { title: string; children: ComponentChildren }): VNode { +function WithTitle({ + title, + children, +}: { + title: string; + children: ComponentChildren; +}): VNode { useEffect(() => { - document.title = `Taler Backoffice: ${title}` - }, [title]) - return <Fragment>{children}</Fragment> + document.title = `${title}`; + }, [title]); + return <Fragment>{children}</Fragment>; } export function Menu({ title }: MenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false) - - return <Match>{({ path }: { path: string }) => { - const titleWithSubtitle = title // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) - return (<WithTitle title={titleWithSubtitle}> - <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}> - <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={titleWithSubtitle} /> - - <Sidebar mobile={mobileOpen} /> - - </div> - </WithTitle> - ) - }}</Match> - + const [mobileOpen, setMobileOpen] = useState(false); + + return ( + <Match> + {({ path }: { path: string }) => { + const titleWithSubtitle = title; // title ? title : (!admin ? getInstanceTitle(path, instance) : getAdminTitle(path, instance)) + return ( + <WithTitle title={titleWithSubtitle}> + <div + class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={titleWithSubtitle} + /> + + <Sidebar mobile={mobileOpen} /> + </div> + </WithTitle> + ); + }} + </Match> + ); } interface NotYetReadyAppMenuProps { @@ -60,37 +72,57 @@ interface NotYetReadyAppMenuProps { interface NotifProps { notification?: Notification; } -export function NotificationCard({ notification: n }: NotifProps): VNode | null { - if (!n) return null - return <div class="notification"> - <div class="columns is-vcentered"> - <div class="column is-12"> - <article class={n.type === 'ERROR' ? "message is-danger" : (n.type === 'WARN' ? "message is-warning" : "message is-info")}> - <div class="message-header"> - <p>{n.message}</p> - </div> - {n.description && - <div class="message-body"> - {n.description} - </div>} - </article> +export function NotificationCard({ + notification: n, +}: NotifProps): VNode | null { + if (!n) return null; + return ( + <div class="notification"> + <div class="columns is-vcentered"> + <div class="column is-12"> + <article + class={ + n.type === "ERROR" + ? "message is-danger" + : n.type === "WARN" + ? "message is-warning" + : "message is-info" + } + > + <div class="message-header"> + <p>{n.message}</p> + </div> + {n.description && <div class="message-body">{n.description}</div>} + </article> + </div> </div> </div> - </div> + ); } -export function NotYetReadyAppMenu({ onLogout, title }: NotYetReadyAppMenuProps): VNode { - const [mobileOpen, setMobileOpen] = useState(false) +export function NotYetReadyAppMenu({ + onLogout, + title, +}: NotYetReadyAppMenuProps): VNode { + const [mobileOpen, setMobileOpen] = useState(false); useEffect(() => { - document.title = `Taler Backoffice: ${title}` - }, [title]) - - return <div class={mobileOpen ? "has-aside-mobile-expanded" : ""} onClick={() => setMobileOpen(false)}> - <NavigationBar onMobileMenu={() => setMobileOpen(!mobileOpen)} title={title} /> - {onLogout && <Sidebar mobile={mobileOpen} />} - </div> - + document.title = `Taler Backoffice: ${title}`; + }, [title]); + + return ( + <div + class="has-aside-mobile-expanded" + // class={mobileOpen ? "has-aside-mobile-expanded" : ""} + onClick={() => setMobileOpen(false)} + > + <NavigationBar + onMobileMenu={() => setMobileOpen(!mobileOpen)} + title={title} + /> + {onLogout && <Sidebar mobile={mobileOpen} />} + </div> + ); } export interface Notification { @@ -99,6 +131,5 @@ export interface Notification { type: MessageType; } -export type ValueOrFunction<T> = T | ((p: T) => T) -export type MessageType = 'INFO' | 'WARN' | 'ERROR' | 'SUCCESS' - +export type ValueOrFunction<T> = T | ((p: T) => T); +export type MessageType = "INFO" | "WARN" | "ERROR" | "SUCCESS"; diff --git a/packages/anastasis-webui/src/components/picker/DatePicker.tsx b/packages/anastasis-webui/src/components/picker/DatePicker.tsx index eb5d8145d..d689db386 100644 --- a/packages/anastasis-webui/src/components/picker/DatePicker.tsx +++ b/packages/anastasis-webui/src/components/picker/DatePicker.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, Component } from "preact"; @@ -34,83 +34,71 @@ interface State { selectYearMode: boolean; currentDate: Date; } -const now = new Date() +const now = new Date(); const monthArrShortFull = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December' -] + "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[] = [] - + "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 - */ + * 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 + 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')); + const date = new Date(element.getAttribute("data-value")); // update the state this.setState({ currentDate: date }); - this.passDateToParent(date) + this.passDateToParent(date); } /** - * returns days in month as array - * @param {number} month the month to display - * @param {number} year the year to display - */ + * 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 @@ -122,15 +110,17 @@ export class DatePicker extends Component<Props, State> { // 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 + 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 }); } @@ -138,51 +128,48 @@ export class DatePicker extends Component<Props, State> { } /** - * Display previous month by updating state - */ + * Display previous month by updating state + */ displayPrevMonth() { if (this.state.displayedMonth <= 0) { this.setState({ displayedMonth: 11, - displayedYear: this.state.displayedYear - 1 + displayedYear: this.state.displayedYear - 1, }); - } - else { + } else { this.setState({ - displayedMonth: this.state.displayedMonth - 1 + displayedMonth: this.state.displayedMonth - 1, }); } } /** - * Display next month by updating state - */ + * Display next month by updating state + */ displayNextMonth() { if (this.state.displayedMonth >= 11) { this.setState({ displayedMonth: 0, - displayedYear: this.state.displayedYear + 1 + displayedYear: this.state.displayedYear + 1, }); - } - else { + } else { this.setState({ - displayedMonth: this.state.displayedMonth + 1 + displayedMonth: this.state.displayedMonth + 1, }); } } /** - * Display the selected month (gets fired when clicking on the date string) - */ + * Display the selected month (gets fired when clicking on the date string) + */ displaySelectedMonth() { if (this.state.selectYearMode) { this.toggleYearSelector(); - } - else { + } else { if (!this.state.currentDate) return false; this.setState({ displayedMonth: this.state.currentDate.getMonth(), - displayedYear: this.state.currentDate.getFullYear() + displayedYear: this.state.currentDate.getFullYear(), }); } } @@ -194,17 +181,21 @@ export class DatePicker extends Component<Props, State> { changeDisplayedYear(e: any) { const element = e.target; this.toggleYearSelector(); - this.setState({ displayedYear: parseInt(element.innerHTML, 10), displayedMonth: 0 }); + this.setState({ + displayedYear: parseInt(element.innerHTML, 10), + displayedMonth: 0, + }); } /** - * Pass the selected date to parent when 'OK' is clicked - */ + * Pass the selected date to parent when 'OK' is clicked + */ passSavedDateDateToParent() { - this.passDateToParent(this.state.currentDate) + this.passDateToParent(this.state.currentDate); } passDateToParent(date: Date) { - if (typeof this.props.dateReceiver === 'function') this.props.dateReceiver(date); + if (typeof this.props.dateReceiver === "function") + this.props.dateReceiver(date); this.closeDatePicker(); } @@ -233,94 +224,133 @@ export class DatePicker extends Component<Props, State> { currentDate: initial, displayedMonth: initial.getMonth(), displayedYear: initial.getFullYear(), - selectYearMode: false - } + selectYearMode: false, + }; } render() { - - const { currentDate, displayedMonth, displayedYear, selectYearMode } = this.state; + const { + currentDate, + displayedMonth, + displayedYear, + selectYearMode, + } = this.state; return ( <div> - <div class={`datePicker ${ this.props.opened && "datePicker--opened"}`}> - + <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()} + <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>} + {!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"> - - {/* + {!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' : '')} + {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>) - } - ) - } - + </span> + ); + })} + </div> </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>} - + )} + + {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 + class="datePicker--background" + onClick={this.closeDatePicker} + style={{ + display: this.props.opened ? "block" : "none", + }} /> - </div> - ) + ); } } - for (let i = 2010; i <= now.getFullYear() + 10; i++) { yearArr.push(i); } diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx index 275c80fa6..7f96cc15b 100644 --- a/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.stories.tsx @@ -15,36 +15,41 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { h, FunctionalComponent } from 'preact'; -import { useState } from 'preact/hooks'; -import { DurationPicker as TestedComponent } from './DurationPicker'; + * + * @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', + title: "Components/Picker/Duration", component: TestedComponent, argTypes: { - onCreate: { action: 'onCreate' }, - goBack: { action: 'goBack' }, - } + 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 +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 + 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 /> -} + const [v, s] = useState<number>(1000000); + return <TestedComponent value={v} onChange={s} days minutes hours seconds />; +}; diff --git a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx index 235a63e2d..8a1faf4d0 100644 --- a/packages/anastasis-webui/src/components/picker/DurationPicker.tsx +++ b/packages/anastasis-webui/src/components/picker/DurationPicker.tsx @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { h, VNode } from "preact"; import { useState } from "preact/hooks"; @@ -30,75 +30,123 @@ export interface Props { seconds?: boolean; days?: boolean; onChange: (value: number) => void; - value: number + 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> +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, + 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 InputNumber({ + initial, + onChange, +}: { + initial: number; + onChange: (n: number) => void; +}) { + const [value, handler] = useState<{ v: string }>({ + v: toTwoDigitString(initial), + }); -function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onChange }: ColProps): VNode { + 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", + }} + /> + ); +} - const cellHeight = 35 +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"> @@ -106,49 +154,58 @@ function DurationColumn({ unit, min = 0, max, value, onIncrease, onDecrease, onC <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>} + {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) : ''} + {value > min ? toTwoDigitString(value - 1) : ""} </div> <div class="rdp-cell rdp-center" key={value}> - {onChange ? - <InputNumber initial={value} onChange={(n) => onChange(n - 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) : ''} + {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>} + {onIncrease && ( + <button + style={{ width: "100%", textAlign: "center", margin: 5 }} + onClick={onIncrease} + > + <span class="icon"> + <i class="mdi mdi-chevron-down" /> + </span> + </button> + )} </div> - </div> </div> </div> ); } - function toTwoDigitString(n: number) { if (n < 10) { return `0${n}`; } return `${n}`; -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/context/anastasis.ts b/packages/anastasis-webui/src/context/anastasis.ts index e7f93ed43..c2e7b2a47 100644 --- a/packages/anastasis-webui/src/context/anastasis.ts +++ b/packages/anastasis-webui/src/context/anastasis.ts @@ -15,19 +15,19 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact'; -import { useContext } from 'preact/hooks'; -import { AnastasisReducerApi } from '../hooks/use-anastasis-reducer'; +import { createContext, h, VNode } from "preact"; +import { useContext } from "preact/hooks"; +import { AnastasisReducerApi } from "../hooks/use-anastasis-reducer"; type Type = AnastasisReducerApi | undefined; -const initial = undefined +const initial = undefined; -const Context = createContext<Type>(initial) +const Context = createContext<Type>(initial); interface Props { value: AnastasisReducerApi; @@ -36,6 +36,6 @@ interface Props { export const AnastasisProvider = ({ value, children }: Props): VNode => { return h(Context.Provider, { value, children }); -} +}; -export const useAnastasisContext = (): Type => useContext(Context);
\ No newline at end of file +export const useAnastasisContext = (): Type => useContext(Context); diff --git a/packages/anastasis-webui/src/context/translation.ts b/packages/anastasis-webui/src/context/translation.ts index 5ceb5d428..a47864d75 100644 --- a/packages/anastasis-webui/src/context/translation.ts +++ b/packages/anastasis-webui/src/context/translation.ts @@ -15,13 +15,13 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact' -import { useContext, useEffect } from 'preact/hooks' -import { useLang } from '../hooks' +import { createContext, h, VNode } from "preact"; +import { useContext, useEffect } from "preact/hooks"; +import { useLang } from "../hooks"; import * as jedLib from "jed"; import { strings } from "../i18n/strings"; @@ -31,13 +31,13 @@ interface Type { changeLanguage: (l: string) => void; } const initial = { - lang: 'en', + lang: "en", handler: null, changeLanguage: () => { // do not change anything - } -} -const Context = createContext<Type>(initial) + }, +}; +const Context = createContext<Type>(initial); interface Props { initial?: string; @@ -45,15 +45,22 @@ interface Props { forceLang?: string; } -export const TranslationProvider = ({ initial, children, forceLang }: Props): VNode => { - const [lang, changeLanguage] = useLang(initial) +export const TranslationProvider = ({ + initial, + children, + forceLang, +}: Props): VNode => { + const [lang, changeLanguage] = useLang(initial); useEffect(() => { if (forceLang) { - changeLanguage(forceLang) + changeLanguage(forceLang); } - }) - const handler = new jedLib.Jed(strings[lang] || strings['en']); - return h(Context.Provider, { value: { lang, handler, changeLanguage }, children }); -} + }); + const handler = new jedLib.Jed(strings[lang] || strings["en"]); + return h(Context.Provider, { + value: { lang, handler, changeLanguage }, + children, + }); +}; -export const useTranslationContext = (): Type => useContext(Context);
\ No newline at end of file +export const useTranslationContext = (): Type => useContext(Context); diff --git a/packages/anastasis-webui/src/declaration.d.ts b/packages/anastasis-webui/src/declaration.d.ts index 2c4b7cb3a..00b3d41d5 100644 --- a/packages/anastasis-webui/src/declaration.d.ts +++ b/packages/anastasis-webui/src/declaration.d.ts @@ -1,20 +1,20 @@ declare module "*.css" { - const mapping: Record<string, string>; - export default mapping; + const mapping: Record<string, string>; + export default mapping; } -declare module '*.svg' { - const content: any; - export default content; +declare module "*.svg" { + const content: any; + export default content; } -declare module '*.jpeg' { - const content: any; - export default content; +declare module "*.jpeg" { + const content: any; + export default content; } -declare module '*.png' { - const content: any; - export default content; +declare module "*.png" { + const content: any; + export default content; } -declare module 'jed' { - const x: any; - export = x; +declare module "jed" { + const x: any; + export = x; } diff --git a/packages/anastasis-webui/src/hooks/async.ts b/packages/anastasis-webui/src/hooks/async.ts index ea3ff6acf..0fc197554 100644 --- a/packages/anastasis-webui/src/hooks/async.ts +++ b/packages/anastasis-webui/src/hooks/async.ts @@ -15,9 +15,9 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { useState } from "preact/hooks"; // import { cancelPendingRequest } from "./backend"; @@ -34,36 +34,39 @@ export interface AsyncOperationApi<T> { error: string | undefined; } -export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: tooLong }: Options = { slowTolerance: 1000 }): AsyncOperationApi<T> { +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 [isSlow, setSlow] = useState(false); const request = async (...args: any) => { if (!fn) return; setLoading(true); const handler = setTimeout(() => { - setSlow(true) - }, tooLong) + setSlow(true); + }, tooLong); try { - console.log("calling async", args) + console.log("calling async", args); const result = await fn(...args); - console.log("async back", result) + console.log("async back", result); setData(result); } catch (error) { setError(error); } setLoading(false); - setSlow(false) - clearTimeout(handler) + setSlow(false); + clearTimeout(handler); }; function cancel() { // cancelPendingRequest() setLoading(false); - setSlow(false) + setSlow(false); } return { @@ -72,6 +75,6 @@ export function useAsync<T>(fn?: (...args: any) => Promise<T>, { slowTolerance: data, isSlow, isLoading, - error + error, }; } diff --git a/packages/anastasis-webui/src/hooks/index.ts b/packages/anastasis-webui/src/hooks/index.ts index 15df4f154..9a1b50a11 100644 --- a/packages/anastasis-webui/src/hooks/index.ts +++ b/packages/anastasis-webui/src/hooks/index.ts @@ -15,81 +15,110 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { StateUpdater, useState } from "preact/hooks"; -export type ValueOrFunction<T> = T | ((p: T) => T) - +export type ValueOrFunction<T> = T | ((p: T) => T); const calculateRootPath = () => { - const rootPath = typeof window !== undefined ? window.location.origin + window.location.pathname : '/' - return rootPath -} - -export function useBackendURL(url?: string): [string, boolean, StateUpdater<string>, () => void] { - const [value, setter] = useNotNullLocalStorage('backend-url', url || calculateRootPath()) - const [triedToLog, setTriedToLog] = useLocalStorage('tried-login') + const rootPath = + typeof window !== undefined + ? window.location.origin + window.location.pathname + : "/"; + return rootPath; +}; + +export function useBackendURL( + url?: string, +): [string, boolean, StateUpdater<string>, () => void] { + const [value, setter] = useNotNullLocalStorage( + "backend-url", + url || calculateRootPath(), + ); + const [triedToLog, setTriedToLog] = useLocalStorage("tried-login"); const checkedSetter = (v: ValueOrFunction<string>) => { - setTriedToLog('yes') - return setter(p => (v instanceof Function ? v(p) : v).replace(/\/$/, '')) - } + setTriedToLog("yes"); + return setter((p) => (v instanceof Function ? v(p) : v).replace(/\/$/, "")); + }; const resetBackend = () => { - setTriedToLog(undefined) - } - return [value, !!triedToLog, checkedSetter, resetBackend] + setTriedToLog(undefined); + }; + return [value, !!triedToLog, checkedSetter, resetBackend]; } -export function useBackendDefaultToken(): [string | undefined, StateUpdater<string | undefined>] { - return useLocalStorage('backend-token') +export function useBackendDefaultToken(): [ + string | undefined, + StateUpdater<string | undefined>, +] { + return useLocalStorage("backend-token"); } -export function useBackendInstanceToken(id: string): [string | undefined, StateUpdater<string | undefined>] { - const [token, setToken] = useLocalStorage(`backend-token-${id}`) - const [defaultToken, defaultSetToken] = useBackendDefaultToken() +export function useBackendInstanceToken( + id: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [token, setToken] = useLocalStorage(`backend-token-${id}`); + const [defaultToken, defaultSetToken] = useBackendDefaultToken(); // instance named 'default' use the default token - if (id === 'default') { - return [defaultToken, defaultSetToken] + if (id === "default") { + return [defaultToken, defaultSetToken]; } - return [token, setToken] + return [token, setToken]; } export function useLang(initial?: string): [string, StateUpdater<string>] { - const browserLang = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined; - const defaultLang = (browserLang || initial || 'en').substring(0, 2) - return useNotNullLocalStorage('lang-preference', defaultLang) + const browserLang = + typeof window !== "undefined" + ? navigator.language || (navigator as any).userLanguage + : undefined; + const defaultLang = (browserLang || initial || "en").substring(0, 2); + return useNotNullLocalStorage("lang-preference", defaultLang); } -export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] { - const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; +export function useLocalStorage( + key: string, + initialValue?: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [storedValue, setStoredValue] = useState<string | undefined>((): + | string + | undefined => { + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); - const setValue = (value?: string | ((val?: string) => string | undefined)) => { - setStoredValue(p => { - const toStore = value instanceof Function ? value(p) : value + const setValue = ( + value?: string | ((val?: string) => string | undefined), + ) => { + setStoredValue((p) => { + const toStore = value instanceof Function ? value(p) : value; if (typeof window !== "undefined") { if (!toStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, toStore); } } - return toStore - }) + return toStore; + }); }; return [storedValue, setValue]; } -export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] { +export function useNotNullLocalStorage( + key: string, + initialValue: string, +): [string, StateUpdater<string>] { const [storedValue, setStoredValue] = useState<string>((): string => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); const setValue = (value: string | ((val: string) => string)) => { @@ -97,7 +126,7 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri setStoredValue(valueToStore); if (typeof window !== "undefined") { if (!valueToStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, valueToStore); } @@ -106,5 +135,3 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri return [storedValue, setValue]; } - - diff --git a/packages/anastasis-webui/src/i18n/index.tsx b/packages/anastasis-webui/src/i18n/index.tsx index 63c8e1934..6e2c4e79a 100644 --- a/packages/anastasis-webui/src/i18n/index.tsx +++ b/packages/anastasis-webui/src/i18n/index.tsx @@ -27,23 +27,25 @@ import { useTranslationContext } from "../context/translation"; export function useTranslator() { const ctx = useTranslationContext(); - const jed = ctx.handler - return function str(stringSeq: TemplateStringsArray, ...values: any[]): string { + const jed = ctx.handler; + return function str( + stringSeq: TemplateStringsArray, + ...values: any[] + ): string { const s = toI18nString(stringSeq); - if (!s) return s + if (!s) return s; const tr = jed .translate(s) .ifPlural(1, s) .fetch(...values); return tr; - } + }; } - /** * Convert template strings to a msgid */ - function toI18nString(stringSeq: ReadonlyArray<string>): string { +function toI18nString(stringSeq: ReadonlyArray<string>): string { let s = ""; for (let i = 0; i < stringSeq.length; i++) { s += stringSeq[i]; @@ -54,7 +56,6 @@ export function useTranslator() { return s; } - interface TranslateSwitchProps { target: number; children: ComponentChildren; @@ -110,7 +111,7 @@ function getTranslatedChildren( // Text result.push(tr[i]); } else { - const childIdx = Number.parseInt(tr[i],10) - 1; + const childIdx = Number.parseInt(tr[i], 10) - 1; result.push(placeholderChildren[childIdx]); } } @@ -131,9 +132,9 @@ function getTranslatedChildren( */ export function Translate({ children }: TranslateProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation: string = ctx.handler.ngettext(s, s, 1); - const result = getTranslatedChildren(translation, children) + const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; } @@ -154,14 +155,16 @@ export function TranslateSwitch({ children, target }: TranslateSwitchProps) { let plural: VNode<TranslationPluralProps> | undefined; // const children = this.props.children; if (children) { - (children instanceof Array ? children : [children]).forEach((child: any) => { - if (child.type === TranslatePlural) { - plural = child; - } - if (child.type === TranslateSingular) { - singular = child; - } - }); + (children instanceof Array ? children : [children]).forEach( + (child: any) => { + if (child.type === TranslatePlural) { + plural = child; + } + if (child.type === TranslateSingular) { + singular = child; + } + }, + ); } if (!singular || !plural) { console.error("translation not found"); @@ -182,9 +185,12 @@ interface TranslationPluralProps { /** * See [[TranslateSwitch]]. */ -export function TranslatePlural({ children, target }: TranslationPluralProps): VNode { +export function TranslatePlural({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, 1); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; @@ -193,11 +199,13 @@ export function TranslatePlural({ children, target }: TranslationPluralProps): V /** * See [[TranslateSwitch]]. */ -export function TranslateSingular({ children, target }: TranslationPluralProps): VNode { +export function TranslateSingular({ + children, + target, +}: TranslationPluralProps): VNode { const s = stringifyChildren(children); - const ctx = useTranslationContext() + const ctx = useTranslationContext(); const translation = ctx.handler.ngettext(s, s, target); const result = getTranslatedChildren(translation, children); return <Fragment>{result}</Fragment>; - } diff --git a/packages/anastasis-webui/src/i18n/strings.ts b/packages/anastasis-webui/src/i18n/strings.ts index b4f376ce0..d12e63e88 100644 --- a/packages/anastasis-webui/src/i18n/strings.ts +++ b/packages/anastasis-webui/src/i18n/strings.ts @@ -15,30 +15,30 @@ */ /*eslint quote-props: ["error", "consistent"]*/ -export const strings: {[s: string]: any} = {}; +export const strings: { [s: string]: any } = {}; -strings['de'] = { - "domain": "messages", - "locale_data": { - "messages": { +strings["de"] = { + domain: "messages", + locale_data: { + messages: { "": { - "domain": "messages", - "plural_forms": "nplurals=2; plural=(n != 1);", - "lang": "" + domain: "messages", + plural_forms: "nplurals=2; plural=(n != 1);", + lang: "", }, - } - } + }, + }, }; -strings['en'] = { - "domain": "messages", - "locale_data": { - "messages": { +strings["en"] = { + domain: "messages", + locale_data: { + messages: { "": { - "domain": "messages", - "plural_forms": "nplurals=2; plural=(n != 1);", - "lang": "" + domain: "messages", + plural_forms: "nplurals=2; plural=(n != 1);", + lang: "", }, - } - } + }, + }, }; diff --git a/packages/anastasis-webui/src/index.ts b/packages/anastasis-webui/src/index.ts index e78b9c194..4bd7b28f3 100644 --- a/packages/anastasis-webui/src/index.ts +++ b/packages/anastasis-webui/src/index.ts @@ -1,4 +1,4 @@ -import App from './components/app'; -import './scss/main.scss'; +import App from "./components/app"; +import "./scss/main.scss"; export default App; diff --git a/packages/anastasis-webui/src/manifest.json b/packages/anastasis-webui/src/manifest.json index 6b44a2b31..2752dad77 100644 --- a/packages/anastasis-webui/src/manifest.json +++ b/packages/anastasis-webui/src/manifest.json @@ -18,4 +18,4 @@ "sizes": "512x512" } ] -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx index 43807fefe..9b067127d 100644 --- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,24 +15,23 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AddingProviderScreen as TestedComponent } from './AddingProviderScreen'; + * + * @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', + title: "Pages/ManageProvider", component: TestedComponent, args: { - order: 4, + order: 1, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; @@ -41,10 +39,31 @@ export const NewProvider = createExample(TestedComponent, { ...reducerStatesExample.authEditing, } as ReducerState); -export const NewSMSProvider = createExample(TestedComponent, { +export const NewProviderWithoutProviderList = createExample(TestedComponent, { ...reducerStatesExample.authEditing, -} as ReducerState, { providerType: 'sms'}); + authentication_providers: {}, +} as ReducerState); -export const NewIBANProvider = createExample(TestedComponent, { - ...reducerStatesExample.authEditing, -} as ReducerState, { providerType: 'iban' }); +export const NewVideoProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "video" }, +); + +export const NewSmsProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "sms" }, +); + +export const NewIBANProvider = createExample( + TestedComponent, + { + ...reducerStatesExample.authEditing, + } as ReducerState, + { providerType: "iban" }, +); diff --git a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx index 9c83da49e..96b38e92d 100644 --- a/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AddingProviderScreen.tsx @@ -1,101 +1,260 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; -import { useLayoutEffect, useRef, useState } from "preact/hooks"; +import { useEffect, useRef, useState } from "preact/hooks"; import { TextInput } from "../../components/fields/TextInput"; +import { useAnastasisContext } from "../../context/anastasis"; import { authMethods, KnownAuthMethods } from "./authMethod"; import { AnastasisClientFrame } from "./index"; interface Props { providerType?: KnownAuthMethods; - cancel: () => void; + onCancel: () => void; } -export function AddingProviderScreen({ providerType, cancel }: Props): VNode { + +async function testProvider( + url: string, + expectedMethodType?: string, +): Promise<void> { + try { + const response = await fetch(new URL("config", url).href); + const json = await response.json().catch((d) => ({})); + if (!("methods" in json) || !Array.isArray(json.methods)) { + throw Error( + "This provider doesn't have authentication method. Check the provider URL", + ); + } + console.log("expected", expectedMethodType); + if (!expectedMethodType) { + return; + } + let found = false; + for (let i = 0; i < json.methods.length && !found; i++) { + found = json.methods[i].type === expectedMethodType; + } + if (!found) { + throw Error( + `This provider does not support authentication method ${expectedMethodType}`, + ); + } + return; + } catch (e) { + console.log("error", e); + const error = + e instanceof Error + ? Error( + `There was an error testing this provider, try another one. ${e.message}`, + ) + : Error(`There was an error testing this provider, try another one.`); + throw error; + } +} + +export function AddingProviderScreen({ providerType, onCancel }: Props): VNode { + const reducer = useAnastasisContext(); + const [providerURL, setProviderURL] = useState(""); - const [error, setError] = useState<string | undefined>() - const providerLabel = providerType ? authMethods[providerType].label : undefined + const [error, setError] = useState<string | undefined>(); + const [testing, setTesting] = useState(false); + const providerLabel = providerType + ? authMethods[providerType].label + : undefined; - function testProvider(): void { - setError(undefined) + //FIXME: move this timeout logic into a hook + const timeout = useRef<number | undefined>(undefined); + useEffect(() => { + if (timeout) window.clearTimeout(timeout.current); + timeout.current = window.setTimeout(async () => { + const url = providerURL.endsWith("/") ? providerURL : providerURL + "/"; + if (!providerURL || authProviders.includes(url)) return; + try { + setTesting(true); + await testProvider(url, providerType); + // this is use as tested but everything when ok + // undefined will mean that the field is not dirty + setError(""); + } catch (e) { + console.log("tuvieja", e); + if (e instanceof Error) setError(e.message); + } + setTesting(false); + }, 200); + }, [providerURL, reducer]); - 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}`) - }) + if (!reducer) { + return <div>no reducer in context</div>; + } + if ( + !reducer.currentReducerState || + !("authentication_providers" in reducer.currentReducerState) + ) { + return <div>invalid state</div>; } - function addProvider(): void { - // addAuthMethod({ - // authentication_method: { - // type: "sms", - // instructions: `SMS to ${providerURL}`, - // challenge: encodeCrock(stringToBytes(providerURL)), - // }, - // }); + + async function addProvider(provider_url: string): Promise<void> { + await reducer?.transition("add_provider", { provider_url }); + onCancel(); + } + function deleteProvider(provider_url: string): void { + reducer?.transition("delete_provider", { provider_url }); } - const inputRef = useRef<HTMLInputElement>(null); - useLayoutEffect(() => { - inputRef.current?.focus(); - }, []); - let errors = !providerURL ? 'Add provider URL' : undefined + const allAuthProviders = + reducer.currentReducerState.authentication_providers || {}; + const authProviders = Object.keys(allAuthProviders).filter((provUrl) => { + const p = allAuthProviders[provUrl]; + if (!providerLabel) { + return p && "currency" in p; + } else { + return ( + p && + "currency" in p && + p.methods.findIndex((m) => m.type === providerType) !== -1 + ); + } + }); + + let errors = !providerURL ? "Add provider URL" : undefined; + let url: string | undefined; try { - new URL(providerURL) + url = new URL("", providerURL).href; } catch { - errors = 'Check the URL' + errors = "Check the URL"; } if (!!error && !errors) { - errors = error + errors = error; + } + if (!errors && authProviders.includes(url!)) { + errors = "That provider is already known"; } return ( - <AnastasisClientFrame hideNav - title={!providerLabel ? `Backup: Adding a provider` : `Backup: Adding a ${providerLabel} provider`} - hideNext={errors}> + <AnastasisClientFrame + hideNav + title="Backup: Manage providers" + hideNext={errors} + > <div> - <p> - Add a provider url {errors} - </p> + {!providerLabel ? ( + <p>Add a provider url</p> + ) : ( + <p>Add a provider url for a {providerLabel} service</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> + error={errors} + bind={[providerURL, setProviderURL]} + /> </div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={cancel}>Cancel</button> + <p class="block">Example: https://kudos.demo.anastasis.lu</p> + {testing && <p class="has-text-info">Testing</p>} + + <div + class="block" + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> <span data-tooltip={errors}> - <button class="button is-info" disabled={errors !== undefined} onClick={addProvider}>Add</button> + <button + class="button is-info" + disabled={error !== "" || testing} + onClick={() => addProvider(url!)} + > + Add + </button> </span> </div> + + {authProviders.length > 0 ? ( + !providerLabel ? ( + <p class="subtitle">Current providers</p> + ) : ( + <p class="subtitle"> + Current providers for {providerLabel} service + </p> + ) + ) : !providerLabel ? ( + <p class="subtitle">No known providers, add one.</p> + ) : ( + <p class="subtitle">No known providers for {providerLabel} service</p> + )} + + {authProviders.map((k) => { + const p = allAuthProviders[k] as AuthenticationProviderStatusOk; + return <TableRow url={k} info={p} onDelete={deleteProvider} />; + })} </div> </AnastasisClientFrame> ); } +function TableRow({ + url, + info, + onDelete, +}: { + onDelete: (s: string) => void; + url: string; + info: AuthenticationProviderStatusOk; +}) { + const [status, setStatus] = useState("checking"); + useEffect(function () { + testProvider(url.endsWith("/") ? url.substring(0, url.length - 1) : url) + .then(function () { + setStatus("responding"); + }) + .catch(function () { + setStatus("failed to contact"); + }); + }); + return ( + <div + class="box" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div> + <div class="subtitle">{url}</div> + <dl> + <dt> + <b>Business Name</b> + </dt> + <dd>{info.business_name}</dd> + <dt> + <b>Supported methods</b> + </dt> + <dd>{info.methods.map((m) => m.type).join(",")}</dd> + <dt> + <b>Maximum storage</b> + </dt> + <dd>{info.storage_limit_in_megabytes} Mb</dd> + <dt> + <b>Status</b> + </dt> + <dd>{status}</dd> + </dl> + </div> + <div + class="block" + style={{ + marginTop: "auto", + marginBottom: "auto", + display: "flex", + justifyContent: "space-between", + flexDirection: "column", + }} + > + <button class="button is-danger" onClick={() => onDelete(url)}> + Remove + </button> + </div> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx index 549686616..d48e94403 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,76 +15,83 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AttributeEntryScreen as TestedComponent } from './AttributeEntryScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { AttributeEntryScreen as TestedComponent } from "./AttributeEntryScreen"; export default { - title: 'Pages/AttributeEntryScreen', + title: "Pages/PersonalInformation", component: TestedComponent, args: { - order: 4, + order: 3, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const Backup = createExample(TestedComponent, { ...reducerStatesExample.backupAttributeEditing, - required_attributes: [{ - name: 'first name', - label: 'first', - type: 'string', - uuid: 'asdasdsa1', - widget: 'wid', - }, { - name: 'last name', - label: 'second', - type: 'string', - uuid: 'asdasdsa2', - widget: 'wid', - }, { - name: 'birthdate', - label: 'birthdate', - type: 'date', - uuid: 'asdasdsa3', - widget: 'calendar', - }] + required_attributes: [ + { + name: "first name", + label: "first", + type: "string", + uuid: "asdasdsa1", + widget: "wid", + }, + { + name: "last name", + label: "second", + type: "string", + uuid: "asdasdsa2", + widget: "wid", + }, + { + name: "birthdate", + label: "birthdate", + type: "date", + uuid: "asdasdsa3", + widget: "calendar", + }, + ], } as ReducerState); export const Recovery = createExample(TestedComponent, { ...reducerStatesExample.recoveryAttributeEditing, - required_attributes: [{ - name: 'first', - label: 'first', - type: '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', - }] + 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 + required_attributes: undefined, } as ReducerState); const allWidgets = [ @@ -108,23 +114,22 @@ const allWidgets = [ "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" + 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 => ({ + required_attributes: allWidgets.map((w) => ({ name: w, label: `widget: ${w}`, type: typeForWidget(w), uuid: `uuid-${w}`, - widget: w - })) + widget: w, + })), } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx index f86994c97..1b50779e0 100644 --- a/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AttributeEntryScreen.tsx @@ -1,33 +1,42 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { UserAttributeSpec, validators } from "anastasis-core"; -import { Fragment, h, VNode } from "preact"; +import { isAfter, parse } from "date-fns"; +import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { DateInput } from "../../components/fields/DateInput"; +import { PhoneNumberInput } from "../../components/fields/NumberInput"; +import { TextInput } from "../../components/fields/TextInput"; import { useAnastasisContext } from "../../context/anastasis"; +import { ConfirmModal } from "./ConfirmModal"; 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() - const state = reducer?.currentReducerState - const currentIdentityAttributes = state && "identity_attributes" in state ? (state.identity_attributes || {}) : {} - const [attrs, setAttrs] = useState<Record<string, string>>(currentIdentityAttributes); + const reducer = useAnastasisContext(); + const state = reducer?.currentReducerState; + const currentIdentityAttributes = + state && "identity_attributes" in state + ? state.identity_attributes || {} + : {}; + const [attrs, setAttrs] = useState<Record<string, string>>( + currentIdentityAttributes, + ); + const [askUserIfSure, setAskUserIfSure] = useState(false); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || !("required_attributes" in reducer.currentReducerState)) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + !("required_attributes" in reducer.currentReducerState) + ) { + return <div>invalid state</div>; } - const reqAttr = reducer.currentReducerState.required_attributes || [] + 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 + const value = attrs[spec.name]; + const error = checkIfValid(value, spec); + hasErrors = hasErrors || error !== undefined; return ( <AttributeEntryField key={i} @@ -35,23 +44,42 @@ export function AttributeEntryScreen(): VNode { setValue={(v: string) => setAttrs({ ...attrs, [spec.name]: v })} spec={spec} errorMessage={error} - value={value} /> + onConfirm={() => { + if (!hasErrors) { + setAskUserIfSure(true) + } + }} + value={value} + /> ); - }) + }); return ( <AnastasisClientFrame title={withProcessLabel(reducer, "Who are you?")} hideNext={hasErrors ? "Complete the form." : undefined} - onNext={() => reducer.transition("enter_user_attributes", { - identity_attributes: attrs, - })} + onNext={async () => setAskUserIfSure(true) } > - <div class="columns" style={{ maxWidth: 'unset' }}> - <div class="column is-half"> - {fieldList} - </div> - <div class="column is-is-half" > + {askUserIfSure ? ( + <ConfirmModal + active + onCancel={() => setAskUserIfSure(false)} + description="The values in the form must be correct" + label="I am sure" + cancelLabel="Wait, I want to check" + onConfirm={() => reducer.transition("enter_user_attributes", { + identity_attributes: attrs, + }).then(() => setAskUserIfSure(false) )} + > + You personal information is used to define the location where your + secret will be safely stored. If you forget what you have entered or + if there is a misspell you will be unable to recover your secret. + </ConfirmModal> + ) : null} + + <div class="columns" style={{ maxWidth: "unset" }}> + <div class="column">{fieldList}</div> + <div class="column"> <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> @@ -62,9 +90,12 @@ export function AttributeEntryScreen(): VNode { </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> + <li> + <span class="icon is-right"> + <i class="mdi mdi-circle-small" /> + </span> + The non-hashed version is not shared + </li> </ul> </div> </div> @@ -78,39 +109,43 @@ interface AttributeEntryFieldProps { setValue: (newValue: string) => void; spec: UserAttributeSpec; errorMessage: string | undefined; + onConfirm: () => void; } -const possibleBirthdayYear: Array<number> = [] +const possibleBirthdayYear: Array<number> = []; for (let i = 0; i < 100; i++) { - possibleBirthdayYear.push(2020 - i) + possibleBirthdayYear.push(2020 - i); } function AttributeEntryField(props: AttributeEntryFieldProps): VNode { - return ( <div> - {props.spec.type === 'date' && + {props.spec.type === "date" && <DateInput grabFocus={props.isFirst} label={props.spec.label} years={possibleBirthdayYear} + onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} - />} + /> + } {props.spec.type === 'number' && - <NumberInput + <PhoneNumberInput grabFocus={props.isFirst} label={props.spec.label} + onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} /> } - {props.spec.type === 'string' && + {props.spec.type === "string" && ( <TextInput grabFocus={props.isFirst} label={props.spec.label} + onConfirm={props.onConfirm} error={props.errorMessage} bind={[props.value, props.setValue]} /> - } + )} <div class="block"> This stays private <span class="icon is-right"> @@ -120,40 +155,43 @@ function AttributeEntryField(props: AttributeEntryFieldProps): VNode { </div> ); } -const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/ - +const YEAR_REGEX = /^[0-9]+-[0-9]+-[0-9]+$/; -function checkIfValid(value: string, spec: UserAttributeSpec): string | undefined { - const pattern = spec['validation-regex'] +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 re = new RegExp(pattern); + if (!re.test(value)) return "The value is invalid"; } - const logic = spec['validation-logic'] + 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' + if (func && typeof func === "function" && !func(value)) + return "Please check the value"; } - const optional = spec.optional + const optional = spec.optional; if (!optional && !value) { - return 'This value is required' + return "This value is required"; } if ("date" === spec.type) { if (!YEAR_REGEX.test(value)) { - return "The date doesn't follow the format" + return "The date doesn't follow the format"; } try { - const v = parse(value, 'yyyy-MM-dd', new Date()); + 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" + 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" + return "A birthdate cannot be in the future"; } } catch (e) { - return "Could not parse the date" + return "Could not parse the date"; } } - return undefined + return undefined; } diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx index 5077c3eb0..8acf1c8c8 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,78 +15,84 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { AuthenticationEditorScreen as TestedComponent } from './AuthenticationEditorScreen'; + * + * @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/backup/AuthenticationEditorScreen', + title: "Pages/backup/AuthorizationMethod", component: TestedComponent, args: { - order: 5, + order: 4, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.authEditing); +export const InitialState = createExample( + TestedComponent, + reducerStatesExample.authEditing, +); export const OneAuthMethodConfigured = createExample(TestedComponent, { ...reducerStatesExample.authEditing, - authentication_methods: [{ - type: 'question', - instructions: 'what time is it?', - challenge: 'asd', - }] + 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', - }] + 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: [] + authentication_methods: [], } as ReducerState); - - // type: string; - // instructions: string; - // challenge: string; - // mime_type?: string; diff --git a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx index 93ca81194..91195971d 100644 --- a/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/AuthenticationEditorScreen.tsx @@ -1,61 +1,85 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { AuthMethod } from "anastasis-core"; +import { AuthMethod, ReducerStateBackup } 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 { authMethods, KnownAuthMethods } from "./authMethod"; +import { AddingProviderScreen } from "./AddingProviderScreen"; +import { + authMethods, + AuthMethodSetupProps, + AuthMethodWithRemove, + isKnownAuthMethods, + KnownAuthMethods, +} from "./authMethod"; +import { ConfirmModal } from "./ConfirmModal"; import { AnastasisClientFrame } from "./index"; - - -const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T> +const getKeys = Object.keys as <T extends object>(obj: T) => Array<keyof T>; export function AuthenticationEditorScreen(): VNode { - const [noProvidersAck, setNoProvidersAck] = useState(false) - const [selectedMethod, setSelectedMethod] = useState<KnownAuthMethods | undefined>(undefined); - const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined) + const [noProvidersAck, setNoProvidersAck] = useState(false); + const [selectedMethod, setSelectedMethod] = useState< + KnownAuthMethods | undefined + >(undefined); + const [tooFewAuths, setTooFewAuths] = useState(false); + const [manageProvider, setManageProvider] = useState<string | undefined>( + undefined, + ); - const reducer = useAnastasisContext() + // const [addingProvider, setAddingProvider] = useState<string | undefined>(undefined) + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + 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; + const configuredAuthMethods: AuthMethod[] = + reducer.currentReducerState.authentication_methods ?? []; function removeByIndex(index: number): void { - if (reducer) reducer.transition("delete_authentication", { - authentication_method: index, - }) + if (reducer) + reducer.transition("delete_authentication", { + authentication_method: index, + }); } - const camByType: { [s: string]: AuthMethodWithRemove[] } = {} + 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) + 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]; - if ("http_status" in p && (!("error_code" in p)) && p.methods) { + if ("http_status" in p && !("error_code" in p) && p.methods) { for (const meth of p.methods) { authAvailableSet.add(meth.type); } } } + if (manageProvider !== undefined) { + return ( + <AddingProviderScreen + onCancel={() => setManageProvider(undefined)} + providerType={ + isKnownAuthMethods(manageProvider) ? manageProvider : undefined + } + /> + ); + } + if (selectedMethod) { const cancel = (): void => setSelectedMethod(undefined); const addMethod = (args: any): void => { @@ -63,120 +87,154 @@ export function AuthenticationEditorScreen(): VNode { setSelectedMethod(undefined); }; - const AuthSetup = authMethods[selectedMethod].screen ?? AuthMethodNotImplemented; - return (<Fragment> - <AuthSetup - cancel={cancel} - configured={camByType[selectedMethod] || []} - addAuthMethod={addMethod} - method={selectedMethod} /> - - {!authAvailableSet.has(selectedMethod) && <ConfirmModal active - onCancel={cancel} description="No providers founds" label="Add a provider manually" - onConfirm={() => { - null - }} - > - We have found no trusted cloud providers for your recovery secret. You can add a provider manually. - To add a provider you must know the provider URL (e.g. https://provider.com) - <p> - <a>More about cloud providers</a> - </p> - </ConfirmModal>} - - </Fragment> + const AuthSetup = + authMethods[selectedMethod].setup ?? AuthMethodNotImplemented; + return ( + <Fragment> + <AuthSetup + cancel={cancel} + configured={camByType[selectedMethod] || []} + addAuthMethod={addMethod} + method={selectedMethod} + /> + + {!authAvailableSet.has(selectedMethod) && ( + <ConfirmModal + active + onCancel={cancel} + description="No providers founds" + label="Add a provider manually" + onConfirm={async () => { + setManageProvider(selectedMethod); + }} + > + <p> + We have found no Anastasis providers that support this + authentication method. You can add a provider manually. To add a + provider you must know the provider URL (e.g. + https://provider.com) + </p> + <p> + <a>Learn more about Anastasis providers</a> + </p> + </ConfirmModal> + )} + </Fragment> ); } - if (addingProvider !== undefined) { - return <div /> - } - function MethodButton(props: { method: KnownAuthMethods }): VNode { - if (authMethods[props.method].skip) return <div /> - + if (authMethods[props.method].skip) return <div />; + return ( <div class="block"> <button - style={{ justifyContent: 'space-between' }} + 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 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" > + {!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> - } + )} + {camByType[props.method] && ( + <span class="tag is-info">{camByType[props.method].length}</span> + )} </button> </div> ); } - const errors = !haveMethodsConfigured ? "There is not enough authentication methods." : undefined; + const errors = configuredAuthMethods.length < 2 ? "There is not enough authentication methods." : undefined; + const handleNext = async () => { + const st = reducer.currentReducerState as ReducerStateBackup; + if ((st.authentication_methods ?? []).length <= 2) { + setTooFewAuths(true); + } else { + await reducer.transition("next", {}); + } + }; return ( - <AnastasisClientFrame title="Backup: Configure Authentication Methods" hideNext={errors}> + <AnastasisClientFrame + title="Backup: Configure Authentication Methods" + hideNext={errors} + onNext={handleNext} + > <div class="columns"> - <div class="column is-half"> + <div class="column"> <div> - {getKeys(authMethods).map(method => <MethodButton key={method} method={method} />)} + {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> - </ConfirmModal>} + {tooFewAuths ? ( + <ConfirmModal + active={tooFewAuths} + onCancel={() => setTooFewAuths(false)} + description="Too few auth methods configured" + label="Proceed anyway" + onConfirm={() => reducer.transition("next", {})} + > + You have selected fewer than 3 authentication methods. We + recommend that you add at least 3. + </ConfirmModal> + ) : null} + {authAvailableSet.size === 0 && ( + <ConfirmModal + active={!noProvidersAck} + onCancel={() => setNoProvidersAck(true)} + description="No providers founds" + label="Add a provider manually" + onConfirm={async () => { + setManageProvider(""); + }} + > + <p> + We have found no Anastasis providers for your chosen country / + currency. You can add a providers manually. To add a provider + you must know the provider URL (e.g. https://provider.com) + </p> + <p> + <a>Learn more about Anastasis providers</a> + </p> + </ConfirmModal> + )} </div> - <div class="column is-half"> + <div class="column"> <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. + When recovering your secret data, 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> + <button + class="button is-info" + onClick={() => setManageProvider("")} + > + Manage backup providers + </button> </p> - {authAvailableSet.size > 0 && <p class="block"> - We couldn't find provider for some of the authentication methods. - </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; -} - function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { return ( <AnastasisClientFrame hideNav title={`Add ${props.method} authentication`}> @@ -186,36 +244,3 @@ function AuthMethodNotImplemented(props: AuthMethodSetupProps): VNode { ); } - -function ConfirmModal({ active, description, onCancel, onConfirm, children, danger, disabled, label = 'Confirm' }: Props): VNode { - return <div class={active ? "modal is-active" : "modal"}> - <div class="modal-background " onClick={onCancel} /> - <div class="modal-card" style={{ maxWidth: 700 }}> - <header class="modal-card-head"> - {!description ? null : <p class="modal-card-title"><b>{description}</b></p>} - <button class="delete " aria-label="close" onClick={onCancel} /> - </header> - <section class="modal-card-body"> - {children} - </section> - <footer class="modal-card-foot"> - <button class="button" onClick={onCancel} >Dismiss</button> - <div class="buttons is-right" style={{ width: '100%' }}> - <button class={danger ? "button is-danger " : "button is-info "} disabled={disabled} onClick={onConfirm} >{label}</button> - </div> - </footer> - </div> - <button class="modal-close is-large " aria-label="close" onClick={onCancel} /> - </div> -} - -interface Props { - active?: boolean; - description?: string; - onCancel?: () => void; - onConfirm?: () => void; - label?: string; - children?: ComponentChildren; - danger?: boolean; - disabled?: boolean; -} diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx index b71a79727..c3ff7e746 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,48 +15,51 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { BackupFinishedScreen as TestedComponent } from './BackupFinishedScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { BackupFinishedScreen as TestedComponent } from "./BackupFinishedScreen"; export default { - title: 'Pages/backup/FinishedScreen', + title: "Pages/backup/Finished", component: TestedComponent, args: { - order: 9, + order: 8, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const WithoutName = createExample(TestedComponent, reducerStatesExample.backupFinished); +export const WithoutName = createExample( + TestedComponent, + reducerStatesExample.backupFinished, +); -export const WithName = createExample(TestedComponent, {...reducerStatesExample.backupFinished, - secret_name: 'super_secret', +export const WithName = createExample(TestedComponent, { + ...reducerStatesExample.backupFinished, + secret_name: "super_secret", } as ReducerState); export const WithDetails = createExample(TestedComponent, { ...reducerStatesExample.backupFinished, - secret_name: 'super_secret', + secret_name: "super_secret", success_details: { - 'http://anastasis.net': { + "https://anastasis.demo.taler.net/": { policy_expiration: { - t_ms: 'never' + t_ms: "never", }, - policy_version: 0 + policy_version: 0, }, - 'http://taler.net': { + "https://kudos.demo.anastasis.lu/": { policy_expiration: { - t_ms: new Date().getTime() + 60*60*24*1000 + t_ms: new Date().getTime() + 60 * 60 * 24 * 1000, }, - policy_version: 1 + policy_version: 1, }, - } + }, } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx index 7938baca4..129f1e9e4 100644 --- a/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/BackupFinishedScreen.tsx @@ -1,44 +1,50 @@ +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { format } from "date-fns"; import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function BackupFinishedScreen(): 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.backup_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const details = reducer.currentReducerState.success_details + const details = reducer.currentReducerState.success_details; + const providers = reducer.currentReducerState.authentication_providers ?? {} - 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>} + return ( + <AnastasisClientFrame hideNav title="Backup success!"> + <p>Your backup is complete.</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 ( - <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> - ); - })} - </div>} - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={() => reducer.back()}>Back</button> - </div> - </AnastasisClientFrame>); + {details && ( + <div class="block"> + <p>The backup is stored by the following providers:</p> + {Object.keys(details).map((url, i) => { + const sd = details[url]; + const p = providers[url] as AuthenticationProviderStatusOk + return ( + <div key={i} class="box"> + <a href={url} target="_blank" rel="noreferrer">{p.business_name}</a> + <p> + version {sd.policy_version} + {sd.policy_expiration.t_ms !== "never" + ? ` expires at: ${format( + new Date(sd.policy_expiration.t_ms), + "dd-MM-yyyy", + )}` + : " without expiration date"} + </p> + </div> + ); + })} + </div> + )} + </AnastasisClientFrame> + ); } diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx index 48115c798..56aee8763 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -20,12 +19,16 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { RecoveryStates, ReducerState } from "anastasis-core"; +import { + ChallengeFeedbackStatus, + RecoveryStates, + ReducerState, +} from "anastasis-core"; import { createExample, reducerStatesExample } from "../../utils"; import { ChallengeOverviewScreen as TestedComponent } from "./ChallengeOverviewScreen"; export default { - title: "Pages/recovery/ChallengeOverviewScreen", + title: "Pages/recovery/SolveChallenge/Overview", component: TestedComponent, args: { order: 5, @@ -176,16 +179,15 @@ export const OnePolicyWithAllTheChallengesInDifferentState = createExample( recovery_information: { policies: [ [ - { uuid: "1" }, - { uuid: "2" }, - { uuid: "3" }, - { uuid: "4" }, - { uuid: "5" }, - { uuid: "6" }, - { uuid: "7" }, - { uuid: "8" }, - { uuid: "9" }, - { uuid: "10" }, + { uuid: "uuid-1" }, + { uuid: "uuid-2" }, + { uuid: "uuid-3" }, + { uuid: "uuid-4" }, + { uuid: "uuid-5" }, + { uuid: "uuid-6" }, + { uuid: "uuid-7" }, + { uuid: "uuid-8" }, + { uuid: "uuid-9" }, ], ], challenges: [ @@ -193,20 +195,96 @@ export const OnePolicyWithAllTheChallengesInDifferentState = createExample( cost: "USD:1", instructions: 'in state "solved"', type: "question", - uuid: "1", + uuid: "uuid-1", }, { cost: "USD:1", instructions: 'in state "message"', type: "question", - uuid: "2", + uuid: "uuid-2", + }, + { + cost: "USD:1", + instructions: 'in state "auth iban"', + type: "question", + uuid: "uuid-3", + }, + { + cost: "USD:1", + instructions: 'in state "payment "', + type: "question", + uuid: "uuid-4", + }, + { + cost: "USD:1", + instructions: 'in state "rate limit"', + type: "question", + uuid: "uuid-5", + }, + { + cost: "USD:1", + instructions: 'in state "redirect"', + type: "question", + uuid: "uuid-6", + }, + { + cost: "USD:1", + instructions: 'in state "server failure"', + type: "question", + uuid: "uuid-7", + }, + { + cost: "USD:1", + instructions: 'in state "truth unknown"', + type: "question", + uuid: "uuid-8", + }, + { + cost: "USD:1", + instructions: 'in state "unsupported"', + type: "question", + uuid: "uuid-9", }, ], }, challenge_feedback: { - 1: { state: "solved" }, - 2: { state: "message", message: "Security question was not solved correctly" }, - // FIXME: add missing feedback states here! + "uuid-1": { state: ChallengeFeedbackStatus.Solved.toString() }, + "uuid-2": { + state: ChallengeFeedbackStatus.Message.toString(), + message: "Challenge should be solved", + }, + "uuid-3": { + state: ChallengeFeedbackStatus.AuthIban.toString(), + challenge_amount: "EUR:1", + credit_iban: "DE12345789000", + business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + }, + "uuid-4": { + state: ChallengeFeedbackStatus.Payment.toString(), + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + "uuid-5": { + state: ChallengeFeedbackStatus.RateLimitExceeded.toString(), + // "error_code": 8121 + }, + "uuid-6": { + state: ChallengeFeedbackStatus.Redirect.toString(), + redirect_url: "https://videoconf.example.com/", + http_status: 303, + }, + "uuid-7": { + state: ChallengeFeedbackStatus.ServerFailure.toString(), + http_status: 500, + error_response: "some error message or error object", + }, + "uuid-8": { + state: ChallengeFeedbackStatus.TruthUnknown.toString(), + // "error_code": 8108 + }, + "uuid-9": { state: ChallengeFeedbackStatus.Unsupported.toString() }, }, } as ReducerState, ); diff --git a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx index ed34bbde2..d0c9b2f5d 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengeOverviewScreen.tsx @@ -3,6 +3,7 @@ import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; import { authMethods, KnownAuthMethods } from "./authMethod"; +import { AsyncButton } from "../../components/AsyncButton"; function OverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { const { feedback } = props; @@ -11,28 +12,37 @@ function OverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { } switch (feedback.state) { case ChallengeFeedbackStatus.Message: - return ( - <div> - <p>{feedback.message}</p> - </div> - ); + return <div class="block has-text-danger">{feedback.message}</div>; + case ChallengeFeedbackStatus.Solved: + return <div />; case ChallengeFeedbackStatus.Pending: case ChallengeFeedbackStatus.AuthIban: return null; + case ChallengeFeedbackStatus.ServerFailure: + return <div class="block has-text-danger">Server error.</div>; case ChallengeFeedbackStatus.RateLimitExceeded: - return <div>Rate limit exceeded.</div>; - case ChallengeFeedbackStatus.Redirect: - return <div>Redirect (FIXME: not supported)</div>; + return ( + <div class="block has-text-danger"> + There were to many failed attempts. + </div> + ); case ChallengeFeedbackStatus.Unsupported: - return <div>Challenge not supported by client.</div>; + return ( + <div class="block has-text-danger"> + This client doesn't support solving this type of challenge. Use + another version or contact the provider. + </div> + ); case ChallengeFeedbackStatus.TruthUnknown: - return <div>Truth unknown</div>; - default: return ( - <div> - <pre>{JSON.stringify(feedback)}</pre> + <div class="block has-text-danger"> + Provider doesn't recognize the challenge of the policy. Contact the + provider for further information. </div> ); + case ChallengeFeedbackStatus.Redirect: + default: + return <div />; } } @@ -72,19 +82,25 @@ export function ChallengeOverviewScreen(): VNode { 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); + 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 }; - }); + return { + isPolicySolved, + challenges, + corrupted: row.length > challenges.length, + }; + }) + .filter((p) => !p.corrupted); const atLeastThereIsOnePolicySolved = policiesWithInfo.find((p) => p.isPolicySolved) !== undefined; @@ -94,25 +110,124 @@ export function ChallengeOverviewScreen(): VNode { : undefined; return ( <AnastasisClientFrame hideNext={errors} title="Recovery: Solve challenges"> - {!policies.length ? ( + {!policiesWithInfo.length ? ( <p class="block"> No policies found, try with another version of the secret </p> - ) : policies.length === 1 ? ( + ) : policiesWithInfo.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. + We have found {policiesWithInfo.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]; + + if (!method) { + return ( + <div + key={uuid} + class="block" + style={{ display: "flex", justifyContent: "space-between" }} + > + <div style={{ display: "flex", alignItems: "center" }}> + <span>unknown challenge</span> + </div> + </div> + ); + } + + function ChallengeButton({ + id, + feedback, + }: { + id: string; + feedback?: ChallengeFeedback; + }): VNode { + async function selectChallenge(): Promise<void> { + if (reducer) { + return reducer.transition("select_challenge", { uuid: id }); + } + } + if (!feedback) { + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Solve + </AsyncButton> + </div> + ); + } + switch (feedback.state) { + case ChallengeFeedbackStatus.ServerFailure: + case ChallengeFeedbackStatus.Unsupported: + case ChallengeFeedbackStatus.TruthUnknown: + case ChallengeFeedbackStatus.RateLimitExceeded: + return <div />; + case ChallengeFeedbackStatus.AuthIban: + case ChallengeFeedbackStatus.Payment: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Pay + </AsyncButton> + </div> + ); + case ChallengeFeedbackStatus.Redirect: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Go to {feedback.redirect_url} + </AsyncButton> + </div> + ); + case ChallengeFeedbackStatus.Solved: + return ( + <div> + <div class="tag is-success is-large">Solved</div> + </div> + ); + default: + return ( + <div> + <AsyncButton + class="button" + disabled={ + atLeastThereIsOnePolicySolved && !policy.isPolicySolved + } + onClick={selectChallenge} + > + Solve + </AsyncButton> + </div> + ); + } + } return ( <div key={uuid} @@ -131,21 +246,8 @@ export function ChallengeOverviewScreen(): VNode { </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> + + <ChallengeButton id={uuid} feedback={info.feedback} /> </div> ); }); @@ -153,11 +255,13 @@ export function ChallengeOverviewScreen(): VNode { const policyName = policy.challenges .map((x) => x.info.type) .join(" + "); + const opa = !atLeastThereIsOnePolicySolved ? undefined : policy.isPolicySolved ? undefined : "0.6"; + return ( <div key={policy_index} diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx index e5fe09e99..8c788e556 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.stories.tsx @@ -15,24 +15,26 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { ChallengePayingScreen as TestedComponent } from './ChallengePayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../utils"; +import { ChallengePayingScreen as TestedComponent } from "./ChallengePayingScreen"; export default { - title: 'Pages/recovery/__ChallengePayingScreen', + title: "Pages/recovery/__ChallengePaying", component: TestedComponent, args: { order: 10, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.challengePaying); +export const Example = createExample( + TestedComponent, + reducerStatesExample.challengePaying, +); diff --git a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx index 84896a2ec..ffcc8fafc 100644 --- a/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ChallengePayingScreen.tsx @@ -3,19 +3,19 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function ChallengePayingScreen(): 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 payments = ['']; //reducer.currentReducerState.payments ?? + const payments = [""]; //reducer.currentReducerState.payments ?? return ( - <AnastasisClientFrame - hideNav - title="Recovery: Challenge Paying" - > + <AnastasisClientFrame hideNav title="Recovery: Challenge Paying"> <p> Some of the providers require a payment to store the encrypted authentication information. diff --git a/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx new file mode 100644 index 000000000..c9c59c1b4 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/ConfirmModal.tsx @@ -0,0 +1,58 @@ +import { differenceInBusinessDays } from "date-fns"; +import { ComponentChildren, h, VNode } from "preact"; +import { useLayoutEffect, useRef } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; + +export interface ConfirmModelProps { + active?: boolean; + description?: string; + onCancel?: () => void; + onConfirm?: () => Promise<void>; + label?: string; + cancelLabel?: string; + children?: ComponentChildren; + danger?: boolean; + disabled?: boolean; +} + +export function ConfirmModal({ + active, description, onCancel, onConfirm, children, danger, disabled, label = "Confirm", cancelLabel = "Dismiss" +}: ConfirmModelProps): 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}> + {cancelLabel} + </button> + <div class="buttons is-right" style={{ width: "100%" }} onKeyDown={(e) => { + if (e.key === 'Escape' && onCancel) onCancel() + }}> + <AsyncButton + grabFocus + class={danger ? "button is-danger " : "button is-info "} + disabled={disabled} + onClick={onConfirm} + > + {label} + </AsyncButton> + </div> + </footer> + </div> + <button + class="modal-close is-large " + aria-label="close" + onClick={onCancel} /> + </div> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx index 6bdb3515d..0948d603e 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.stories.tsx @@ -16,37 +16,42 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ContinentSelectionScreen as TestedComponent } from './ContinentSelectionScreen'; + * + * @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/Location', + title: "Pages/Location", component: TestedComponent, args: { order: 2, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const BackupSelectContinent = createExample(TestedComponent, reducerStatesExample.backupSelectContinent); +export const BackupSelectContinent = createExample( + TestedComponent, + reducerStatesExample.backupSelectContinent, +); export const BackupSelectCountry = createExample(TestedComponent, { ...reducerStatesExample.backupSelectContinent, - selected_continent: 'Testcontinent', + selected_continent: "Testcontinent", } as ReducerState); -export const RecoverySelectContinent = createExample(TestedComponent, reducerStatesExample.recoverySelectContinent); +export const RecoverySelectContinent = createExample( + TestedComponent, + reducerStatesExample.recoverySelectContinent, +); export const RecoverySelectCountry = createExample(TestedComponent, { ...reducerStatesExample.recoverySelectContinent, - selected_continent: 'Testcontinent', + selected_continent: "Testcontinent", } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx index 0e43f982d..aafde6e8c 100644 --- a/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ContinentSelectionScreen.tsx @@ -1,58 +1,81 @@ /* eslint-disable @typescript-eslint/camelcase */ +import { BackupStates, RecoveryStates } from "anastasis-core"; 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() + const reducer = useAnastasisContext(); - //FIXME: remove this when #7056 is fixed - const countryFromReducer = (reducer?.currentReducerState as any).selected_country || "" - const [countryCode, setCountryCode] = useState( countryFromReducer ) + // 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 /> + if ( + !reducer || + !reducer.currentReducerState || + !("continents" in reducer.currentReducerState) + ) { + return <div />; } const selectContinent = (continent: string): void => { - reducer.transition("select_continent", { continent }) + reducer.transition("select_continent", { continent }); }; const selectCountry = (country: string): void => { - setCountryCode(country) + setCountryCode(country); }; - - + const continentList = reducer.currentReducerState.continents || []; const countryList = reducer.currentReducerState.countries || []; - const theContinent = reducer.currentReducerState.selected_continent || "" + const theContinent = reducer.currentReducerState.selected_continent || ""; // const cc = reducer.currentReducerState.selected_country || ""; - const theCountry = countryList.find(c => c.code === countryCode) - const selectCountryAction = () => { + const theCountry = countryList.find((c) => c.code === countryCode); + const selectCountryAction = async () => { //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 + const errors = !theCountry ? "Select a country" : undefined; - return ( - <AnastasisClientFrame hideNext={errors} title={withProcessLabel(reducer, "Where do you live?")} onNext={selectCountryAction}> + const handleBack = async () => { + // We want to go to the start, even if we already selected + // a country. + // FIXME: What if we don't want to lose all information here? + // Can we do some kind of soft reset? + reducer.reset(); + }; - <div class="columns" > + return ( + <AnastasisClientFrame + hideNext={errors} + title={withProcessLabel(reducer, "Where do you live?")} + onNext={selectCountryAction} + onBack={handleBack} + > + <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 => ( + <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> @@ -68,10 +91,17 @@ export function ContinentSelectionScreen(): VNode { <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 => ( + <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> @@ -93,12 +123,37 @@ export function ContinentSelectionScreen(): VNode { </div> <div class="column is-two-third"> <p> - Your location will help us to determine which personal information - ask you for the next step. + Your selection will help us ask right information to uniquely + identify you when you want to recover your secret again. + </p> + <p> + Choose the country that issued most of your long-term legal + documents or personal identifiers. </p> + <div + style={{ + border: "1px solid gray", + borderRadius: "0.5em", + backgroundColor: "#fbfcbd", + padding: "0.5em", + }} + > + <p> + If you just want to try out Anastasis, we recomment that you + choose <b>Testcontinent</b> with <b>Demoland</b>. For this special + country, you will be asked for a simple number and not real, + personal identifiable information. + </p> + {/* + <p> + Because of the diversity of personally identifying information in + different countries and cultures, we do not support all countries + yet. If you want to improve the supported countries,{" "} + <a href="mailto:contact@anastasis.lu">contact us</a>. + </p> */} + </div> </div> </div> - </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx index fc339e48e..4cbeb8308 100644 --- a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.stories.tsx @@ -16,94 +16,126 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { EditPoliciesScreen as TestedComponent } from './EditPoliciesScreen'; + * + * @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', + title: "Pages/backup/ReviewPolicies/EditPolicies", args: { order: 6, }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + 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}); +export const EditingAPolicy = createExample( + TestedComponent, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 1, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 2, + provider: "http://localhost:8086/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "http://localhost:8086/", + }, + ], + }, + ], + authentication_methods: [ + { + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA", + }, + { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "", + }, + { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8", + }, + ], + } as ReducerState, + { index: 0 }, +); +export const CreatingAPolicy = createExample( + TestedComponent, + { + ...reducerStatesExample.policyReview, + policies: [ + { + methods: [ + { + authentication_method: 1, + provider: "https://anastasis.demo.taler.net/", + }, + { + authentication_method: 2, + provider: "http://localhost:8086/", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "http://localhost:8086/", + }, + ], + }, + ], + authentication_methods: [ + { + type: "email", + instructions: "Email to qwe@asd.com", + challenge: "E5VPA", + }, + { + type: "totp", + instructions: "Response code for 'Anastasis'", + challenge: "E5VPA", + }, + { + type: "sms", + instructions: "SMS to 6666-6666", + challenge: "", + }, + { + type: "question", + instructions: "How did the chicken cross the road?", + challenge: "C5SP8", + }, + ], + } as ReducerState, + { index: 3 }, +); diff --git a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx index 85cc96c46..198209399 100644 --- a/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/EditPoliciesScreen.tsx @@ -20,7 +20,6 @@ interface Props { index: number; cancel: () => void; confirm: (changes: MethodProvider[]) => void; - } export interface MethodProvider { @@ -28,106 +27,151 @@ export interface MethodProvider { provider: string; } -export function EditPoliciesScreen({ index: policy_index, cancel, confirm }: Props): VNode { - const [changedProvider, setChangedProvider] = useState<Array<string>>([]) +export function EditPoliciesScreen({ + index: policy_index, + cancel, + confirm, +}: Props): VNode { + const [changedProvider, setChangedProvider] = useState<Array<string>>([]); - 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.backup_state === undefined) { - return <div>invalid state</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 || {}) + const selectableProviders: ProviderInfoByType = {}; + const allProviders = Object.entries( + reducer.currentReducerState.authentication_providers || {}, + ); for (let index = 0; index < allProviders.length; index++) { - const [url, status] = allProviders[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 - }) + 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 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 + 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[] = [] + const newMethods: MethodProvider[] = []; allAuthMethods.forEach((method, index) => { - const oldValue = policy?.methods.find(m => m.authentication_method === index) + const oldValue = policy?.methods.find( + (m) => m.authentication_method === index, + ); if (changedProvider[index] === undefined && oldValue !== undefined) { - newMethods.push(oldValue) + newMethods.push(oldValue); } - if (changedProvider[index] !== undefined && changedProvider[index] !== "") { + if ( + changedProvider[index] !== undefined && + changedProvider[index] !== "" + ) { newMethods.push({ authentication_method: index, - provider: changedProvider[index] - }) + provider: changedProvider[index], + }); } - }) - confirm(newMethods) + }); + 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]; + return ( + <AnastasisClientFrame + hideNav + title={!policy ? "Backup: New Policy" : "Backup: Edit Policy"} + > + <section class="section"> + {!policy ? ( + <p>Creating a new policy #{policy_index}</p> + ) : ( + <p>Editing policy #{policy_index}</p> + )} + {allAuthMethods.map((method, index) => { + //take the url from the updated change or from the policy + const providerURL = + changedProvider[index] === undefined + ? policy?.methods.find((m) => m.authentication_method === index) + ?.provider + : changedProvider[index]; - const type: KnownAuthMethods = method.type as KnownAuthMethods - function changeProviderTo(url: string): void { - const copy = [...changedProvider] - copy[index] = url - setChangedProvider(copy) - } - return ( - <div key={index} class="block" style={{ display: 'flex', alignItems: 'center' }}> - <span class="icon"> - {authMethods[type]?.icon} - </span> - <span> - {method.instructions} - </span> - <span> - <span class="select " > - <select onChange={(e) => changeProviderTo(e.currentTarget.value)} value={providerURL ?? ""}> - <option key="none" value=""> << off >> </option> - {selectableProviders[type]?.map(prov => ( - <option key={prov.url} value={prov.url}> - {prov.url} + const type: KnownAuthMethods = method.type as KnownAuthMethods; + function changeProviderTo(url: string): void { + const copy = [...changedProvider]; + copy[index] = url; + setChangedProvider(copy); + } + return ( + <div + key={index} + class="block" + style={{ display: "flex", alignItems: "center" }} + > + <span class="icon">{authMethods[type]?.icon}</span> + <span>{method.instructions}</span> + <span> + <span class="select "> + <select + onChange={(e) => changeProviderTo(e.currentTarget.value)} + value={providerURL ?? ""} + > + <option key="none" value=""> + {" "} + << off >>{" "} </option> - ))} - </select> + {selectableProviders[type]?.map((prov) => ( + <option key={prov.url} value={prov.url}> + {prov.url} + </option> + ))} + </select> + </span> </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> + </div> + ); + })} + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <span class="buttons"> + <button class="button" onClick={() => setChangedProvider([])}> + Reset + </button> + <button class="button is-info" onClick={sendChanges}> + Confirm + </button> + </span> + </div> + </section> + </AnastasisClientFrame> + ); } diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx index e952ab28d..9bebcfbc9 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,35 +15,40 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { PoliciesPayingScreen as TestedComponent } from './PoliciesPayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { PoliciesPayingScreen as TestedComponent } from "./PoliciesPayingScreen"; export default { - title: 'Pages/backup/PoliciesPayingScreen', + title: "Pages/backup/__PoliciesPaying", component: TestedComponent, args: { - order: 8, + order: 9, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.policyPay); +export const Example = createExample( + TestedComponent, + reducerStatesExample.policyPay, +); export const WithSomePaymentRequest = createExample(TestedComponent, { ...reducerStatesExample.policyPay, - policy_payment_requests: [{ - payto: 'payto://x-taler-bank/bank.taler/account-a', - provider: 'provider1' - }, { - payto: 'payto://x-taler-bank/bank.taler/account-b', - provider: 'provider2' - }] + policy_payment_requests: [ + { + payto: "payto://x-taler-bank/bank.taler/account-a", + provider: "provider1", + }, + { + payto: "payto://x-taler-bank/bank.taler/account-b", + provider: "provider2", + }, + ], } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx index a470f5155..c3568b32d 100644 --- a/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/PoliciesPayingScreen.tsx @@ -3,20 +3,23 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function PoliciesPayingScreen(): 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.backup_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.policy_payment_requests ?? []; - + return ( <AnastasisClientFrame hideNav title="Backup: Recovery Document Payments"> <p> - Some of the providers require a payment to store the encrypted - recovery document. + Some of the providers require a payment to store the encrypted recovery + document. </p> <ul> {payments.map((x, i) => { diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx index 0d2ebb778..1c05cd6e1 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,30 +15,41 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { RecoveryFinishedScreen as TestedComponent } from './RecoveryFinishedScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { RecoveryFinishedScreen as TestedComponent } from "./RecoveryFinishedScreen"; export default { - title: 'Pages/recovery/FinishedScreen', + title: "Pages/recovery/Finished", args: { order: 7, }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const GoodEnding = createExample(TestedComponent, { ...reducerStatesExample.recoveryFinished, - core_secret: { mime: 'text/plain', value: 'hello' } + recovery_document: { + secret_name: "the_name_of_the_secret", + }, + core_secret: { + mime: "text/plain", + value: encodeCrock( + stringToBytes("hello this is my secret, don't tell anybody"), + ), + }, } as ReducerState); -export const BadEnding = createExample(TestedComponent, reducerStatesExample.recoveryFinished); +export const BadEnding = createExample( + TestedComponent, + reducerStatesExample.recoveryFinished, +); diff --git a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx index a61ef9efa..d83482559 100644 --- a/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/RecoveryFinishedScreen.tsx @@ -1,39 +1,80 @@ -import { - bytesToString, - decodeCrock -} from "@gnu-taler/taler-util"; +import { bytesToString, decodeCrock, encodeCrock } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; +import { stringToBytes } from "qrcode-generator"; +import { QR } from "../../components/QR"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function RecoveryFinishedScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); + const [copied, setCopied] = useState(false); + useEffect(() => { + setTimeout(() => { + setCopied(false); + }, 1000); + }, [copied]); 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 encodedSecret = reducer.currentReducerState.core_secret + const secretName = reducer.currentReducerState.recovery_document?.secret_name; + const encodedSecret = reducer.currentReducerState.core_secret; if (!encodedSecret) { - 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> + 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.value)) + const secret = bytesToString(decodeCrock(encodedSecret.value)); + const contentURI = `data:${encodedSecret.mime},${secret}`; + // const fileName = encodedSecret['filename'] + // data:plain/text;base64,asdasd return ( - <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> + <AnastasisClientFrame title="Recovery Success" hideNav> + <h2 class="subtitle">Your secret was recovered</h2> + {secretName && ( + <p class="block"> + <b>Secret name:</b> {secretName} + </p> + )} + <div class="block buttons" disabled={copied}> + <button + class="button" + onClick={() => { + navigator.clipboard.writeText(secret); + setCopied(true); + }} + > + {!copied ? "Copy" : "Copied"} + </button> + <a class="button is-info" download="secret.txt" href={contentURI}> + <div class="icon is-small "> + <i class="mdi mdi-download" /> + </div> + <span>Save as</span> + </a> + </div> + <div class="block"> + <QR text={secret} /> </div> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx index 9f7e26c16..4a1cba6a8 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,44 +15,51 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { ReviewPoliciesScreen as TestedComponent } from './ReviewPoliciesScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { ReviewPoliciesScreen as TestedComponent } from "./ReviewPoliciesScreen"; export default { - title: 'Pages/backup/ReviewPoliciesScreen', + title: "Pages/backup/ReviewPolicies", args: { order: 6, }, component: TestedComponent, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const HasPoliciesButMethodListIsEmpty = createExample(TestedComponent, { ...reducerStatesExample.policyReview, - policies: [{ - methods: [{ - authentication_method: 0, - provider: 'asd' - }, { - authentication_method: 1, - provider: 'asd' - }] - }, { - methods: [{ - authentication_method: 1, - provider: 'asd' - }] - }], - authentication_methods: [] + policies: [ + { + methods: [ + { + authentication_method: 0, + provider: "asd", + }, + { + authentication_method: 1, + provider: "asd", + }, + ], + }, + { + methods: [ + { + authentication_method: 1, + provider: "asd", + }, + ], + }, + ], + authentication_methods: [], } as ReducerState); export const SomePoliciesWithMethods = createExample(TestedComponent, { @@ -63,186 +69,193 @@ export const SomePoliciesWithMethods = createExample(TestedComponent, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" - } - ] + provider: "https://kudos.demo.anastasis.lu/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 0, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 1, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] + provider: "https://anastasis.demo.taler.net/", + }, + ], }, { methods: [ { authentication_method: 2, - provider: "https://kudos.demo.anastasis.lu/" + provider: "https://kudos.demo.anastasis.lu/", }, { authentication_method: 3, - provider: "https://anastasis.demo.taler.net/" + provider: "https://anastasis.demo.taler.net/", }, { authentication_method: 4, - provider: "https://anastasis.demo.taler.net/" - } - ] - } + provider: "https://anastasis.demo.taler.net/", + }, + ], + }, + ], + authentication_methods: [ + { + 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", + }, + { + 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", + }, ], - authentication_methods: [{ - 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" - },{ - type: "totp", - instructions: "Response code for 'Anastasis'", - challenge: "E5VPA" - }, { - type: "sms", - instructions: "SMS to 6666-6666", - challenge: "" - }, { - type: "question", - instructions: "How did the chicken cross the road?", - challenge: "C5SP8" -}] } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx index f93963f67..0ed08e037 100644 --- a/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/ReviewPoliciesScreen.tsx @@ -1,24 +1,33 @@ -/* eslint-disable @typescript-eslint/camelcase */ +import { AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AsyncButton } from "../../components/AsyncButton"; import { useAnastasisContext } from "../../context/anastasis"; import { authMethods, KnownAuthMethods } from "./authMethod"; +import { ConfirmModal } from "./ConfirmModal"; import { EditPoliciesScreen } from "./EditPoliciesScreen"; import { AnastasisClientFrame } from "./index"; export function ReviewPoliciesScreen(): VNode { - const [editingPolicy, setEditingPolicy] = useState<number | undefined>() - const reducer = useAnastasisContext() + const [editingPolicy, setEditingPolicy] = useState<number | undefined>(); + const [confirmReset, setConfirmReset] = useState(false); + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } - const configuredAuthMethods = reducer.currentReducerState.authentication_methods ?? []; + const configuredAuthMethods = + reducer.currentReducerState.authentication_methods ?? []; const policies = reducer.currentReducerState.policies ?? []; + const providers = reducer.currentReducerState.authentication_providers ?? {}; + if (editingPolicy !== undefined) { return ( <EditPoliciesScreen @@ -29,62 +38,145 @@ export function ReviewPoliciesScreen(): VNode { policy_index: editingPolicy, policy: newMethods, }); - setEditingPolicy(undefined) + setEditingPolicy(undefined); }} /> - ) + ); + } + async function resetPolicies(): Promise<void> { + if (!reducer) return Promise.resolve(); + return reducer.runTransaction(async (tx) => { + await tx.transition("back", {}); + await tx.transition("next", {}); + setConfirmReset(false); + }); } - const errors = policies.length < 1 ? 'Need more policies' : undefined + const errors = policies.length < 1 ? "Need more policies" : undefined; return ( - <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> + <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"> + <AsyncButton class="button" onClick={async () => setConfirmReset(true)}> + Reset policies + </AsyncButton> + <button + class="button is-success" + style={{ marginLeft: 10 }} + onClick={() => setEditingPolicy(policies.length)} + > + Add new policy + </button> </div> {policies.map((p, policy_index) => { const methods = p.methods - .map(x => configuredAuthMethods[x.authentication_method] && ({ ...configuredAuthMethods[x.authentication_method], provider: x.provider })) - .filter(x => !!x) + .map( + (x) => + configuredAuthMethods[x.authentication_method] && { + ...configuredAuthMethods[x.authentication_method], + provider: x.provider, + }, + ) + .filter((x) => !!x); + + const policyName = methods.map((x) => x.type).join(" + "); - const policyName = methods.map(x => x.type).join(" + "); + if (p.methods.length > methods.length) { + //there is at least one authentication method that is corrupted + return null; + } return ( - <div key={policy_index} class="box" style={{ display: 'flex', justifyContent: 'space-between' }}> + <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.length && <p>No auth method found</p>} {methods.map((m, i) => { + const p = providers[ + m.provider + ] as AuthenticationProviderStatusOk; return ( - <p key={i} class="block" style={{ display: 'flex', alignItems: 'center' }}> + <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> + {m.instructions} recovery provided by{" "} + <a href={m.provider} target="_blank" rel="noreferrer"> + {p.business_name} + </a> </span> </p> ); })} </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 + 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> ); })} + {confirmReset && ( + <ConfirmModal + active + onCancel={() => setConfirmReset(false)} + description="Do you want to reset the policies to default state?" + label="Reset policies" + cancelLabel="Cancel" + onConfirm={resetPolicies} + > + <p> + All policies will be recalculated based on the authentication + providers configured and any change that you did will be lost + </p> + </ConfirmModal> + )} </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx index 49dd8fca8..3f2c6a245 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,30 +15,29 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SecretEditorScreen as TestedComponent } from './SecretEditorScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SecretEditorScreen as TestedComponent } from "./SecretEditorScreen"; export default { - title: 'Pages/backup/SecretEditorScreen', + title: "Pages/backup/SecretInput", component: TestedComponent, args: { order: 7, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const WithSecretNamePreselected = createExample(TestedComponent, { ...reducerStatesExample.secretEdition, - secret_name: 'someSecretName', + secret_name: "someSecretName", } as ReducerState); export const WithoutName = createExample(TestedComponent, { diff --git a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx index 1b36a1b21..6d4ffbf88 100644 --- a/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretEditorScreen.tsx @@ -1,41 +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 { useAnastasisContext } from "../../context/anastasis"; -import { - AnastasisClientFrame -} from "./index"; +import { AnastasisClientFrame } from "./index"; import { TextInput } from "../../components/fields/TextInput"; -import { FileInput } from "../../components/fields/FileInput"; +import { FileInput, FileTypeContent } from "../../components/fields/FileInput"; export function SecretEditorScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); const [secretValue, setSecretValue] = useState(""); + const [secretFile, _setSecretFile] = useState<FileTypeContent | undefined>( + undefined, + ); + function setSecretFile(v: FileTypeContent | undefined): void { + setSecretValue(""); // reset secret value when uploading a file + _setSecretFile(v); + } - const currentSecretName = reducer?.currentReducerState - && ("secret_name" in reducer.currentReducerState) - && reducer.currentReducerState.secret_name; + const currentSecretName = + reducer?.currentReducerState && + "secret_name" in reducer.currentReducerState && + reducer.currentReducerState.secret_name; const [secretName, setSecretName] = useState(currentSecretName || ""); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } - if (!reducer.currentReducerState || reducer.currentReducerState.backup_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const secretNext = async (): Promise<void> => { + const secret = secretFile + ? { + value: encodeCrock(stringToBytes(secretValue)), + filename: secretFile.name, + mime: secretFile.type, + } + : { + value: encodeCrock(stringToBytes(secretValue)), + mime: "text/plain", + }; return reducer.runTransaction(async (tx) => { await tx.transition("enter_secret_name", { name: secretName, }); await tx.transition("enter_secret", { - secret: { - value: encodeCrock(stringToBytes(secretValue)), - mime: "text/plain", - }, + secret, expiration: { t_ms: new Date().getTime() + 1000 * 60 * 60 * 24 * 365 * 5, }, @@ -43,31 +58,46 @@ export function SecretEditorScreen(): VNode { await tx.transition("next", {}); }); }; + const errors = !secretName + ? "Add a secret name" + : !secretValue && !secretFile + ? "Add a secret value or a choose a file to upload" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) secretNext(); + } return ( <AnastasisClientFrame + hideNext={errors} title="Backup: Provide secret to backup" onNext={() => secretNext()} > - <div> + <div class="block"> <TextInput - label="Secret's name:" + label="Secret name:" + tooltip="The secret name allows you to identify your secret when restoring it. It is a label that you can choose freely." grabFocus + onConfirm={goNextIfNoErrors} bind={[secretName, setSecretName]} /> </div> - <div> + <div class="block"> <TextInput + disabled={!!secretFile} + onConfirm={goNextIfNoErrors} label="Enter the secret as text:" bind={[secretValue, setSecretValue]} /> - <div style={{display:'flex',}}> - or - <FileInput - label="click here" - bind={[secretValue, setSecretValue]} - /> - to import a file - </div> + </div> + <div class="block"> + Or upload a secret file + <FileInput label="Choose file" onChange={setSecretFile} /> + {secretFile && ( + <div> + Uploading secret file <b>{secretFile.name}</b>{" "} + <a onClick={() => setSecretFile(undefined)}>cancel</a> + </div> + )} </div> </AnastasisClientFrame> ); diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx index 6919eebad..01ce3f0a7 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.stories.tsx @@ -15,37 +15,35 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SecretSelectionScreen as TestedComponent } from './SecretSelectionScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SecretSelectionScreen as TestedComponent } from "./SecretSelectionScreen"; export default { - title: 'Pages/recovery/SecretSelectionScreen', + title: "Pages/recovery/SecretSelection", component: TestedComponent, args: { order: 4, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; export const Example = createExample(TestedComponent, { ...reducerStatesExample.secretSelection, recovery_document: { - provider_url: 'https://kudos.demo.anastasis.lu/', - secret_name: 'secretName', + provider_url: "https://kudos.demo.anastasis.lu/", + secret_name: "secretName", version: 1, }, } as ReducerState); - export const NoRecoveryDocumentFound = createExample(TestedComponent, { ...reducerStatesExample.secretSelection, recovery_document: undefined, diff --git a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx index 8aa5ed2f7..7e517abfe 100644 --- a/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SecretSelectionScreen.tsx @@ -1,23 +1,31 @@ +import { AuthenticationProviderStatus, AuthenticationProviderStatusOk } from "anastasis-core"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../components/AsyncButton"; -import { NumberInput } from "../../components/fields/NumberInput"; +import { PhoneNumberInput } from "../../components/fields/NumberInput"; import { useAnastasisContext } from "../../context/anastasis"; +import { AddingProviderScreen } from "./AddingProviderScreen"; import { AnastasisClientFrame } from "./index"; export function SecretSelectionScreen(): VNode { const [selectingVersion, setSelectingVersion] = useState<boolean>(false); - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); - const currentVersion = (reducer?.currentReducerState - && ("recovery_document" in reducer.currentReducerState) - && reducer.currentReducerState.recovery_document?.version) || 0; + const [manageProvider, setManageProvider] = useState(false); + const currentVersion = + (reducer?.currentReducerState && + "recovery_document" in reducer.currentReducerState && + reducer.currentReducerState.recovery_document?.version) || + 0; 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>; } async function doSelectVersion(p: string, n: number): Promise<void> { @@ -31,66 +39,108 @@ export function SecretSelectionScreen(): VNode { }); } - const providerList = Object.keys(reducer.currentReducerState.authentication_providers ?? {}) - const recoveryDocument = reducer.currentReducerState.recovery_document + const provs = reducer.currentReducerState.authentication_providers ?? {}; + const recoveryDocument = reducer.currentReducerState.recovery_document; if (!recoveryDocument) { - return <ChooseAnotherProviderScreen - providers={providerList} selected="" - onChange={(newProv) => doSelectVersion(newProv, 0)} - /> + return ( + <ChooseAnotherProviderScreen + providers={provs} + selected="" + onChange={(newProv) => doSelectVersion(newProv, 0)} + /> + ); } if (selectingVersion) { - return <SelectOtherVersionProviderScreen providers={providerList} - provider={recoveryDocument.provider_url} version={recoveryDocument.version} - onCancel={() => setSelectingVersion(false)} - onConfirm={doSelectVersion} - /> + return ( + <SelectOtherVersionProviderScreen + providers={provs} + provider={recoveryDocument.provider_url} + version={recoveryDocument.version} + onCancel={() => setSelectingVersion(false)} + onConfirm={doSelectVersion} + /> + ); } + if (manageProvider) { + return <AddingProviderScreen onCancel={() => setManageProvider(false)} />; + } + + const provierInfo = provs[recoveryDocument.provider_url] as AuthenticationProviderStatusOk return ( <AnastasisClientFrame title="Recovery: Select secret"> <div class="columns"> <div class="column"> - <div class="box" style={{ border: '2px solid green' }}> - <h1 class="subtitle">{recoveryDocument.provider_url}</h1> + <div class="box" style={{ border: "2px solid green" }}> + <h1 class="subtitle"> + {provierInfo.business_name} + </h1> <div class="block"> - {currentVersion === 0 ? <p> - Set to recover the latest version - </p> : <p> - Set to recover the version number {currentVersion} - </p>} + {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> + <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> + <p> + Secret found, you can select another version or continue to the + challenges solving + </p> + <p class="block"> + <a onClick={() => setManageProvider(true)}> + Manage recovery providers + </a> + </p> </div> </div> </AnastasisClientFrame> ); } - -function ChooseAnotherProviderScreen({ providers, selected, onChange }: { selected: string; providers: string[]; onChange: (prov: string) => void }): VNode { +function ChooseAnotherProviderScreen({ + providers, + selected, + onChange, +}: { + selected: string; + providers: { [url: string]: AuthenticationProviderStatus }; + onChange: (prov: string) => void; +}): VNode { return ( - <AnastasisClientFrame hideNext="Recovery document not found" title="Recovery: Problem"> + <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} + <select + onChange={(e) => onChange(e.currentTarget.value)} + value={selected} + > + <option key="none" disabled selected value=""> + {" "} + Choose a provider{" "} + </option> + {Object.keys(providers).map((url) => { + const p = providers[url] + if (!("methods" in p)) return null + return <option key={url} value={url}> + {p.business_name} </option> - ))} + })} </select> <div class="icon is-small is-left"> <i class="mdi mdi-earth" /> @@ -102,22 +152,37 @@ function ChooseAnotherProviderScreen({ providers, selected, onChange }: { select ); } -function SelectOtherVersionProviderScreen({ providers, provider, version, onConfirm, onCancel }: { onCancel: () => void; provider: string; version: number; providers: string[]; onConfirm: (prov: string, v: number) => Promise<void>; }): VNode { +function SelectOtherVersionProviderScreen({ + providers, + provider, + version, + onConfirm, + onCancel, +}: { + onCancel: () => void; + provider: string; + version: number; + providers: { [url: string]: AuthenticationProviderStatus }; + onConfirm: (prov: string, v: number) => Promise<void>; +}): VNode { const [otherProvider, setOtherProvider] = useState<string>(provider); - const [otherVersion, setOtherVersion] = useState(`${version}`); + const [otherVersion, setOtherVersion] = useState( + version > 0 ? String(version) : "", + ); + const otherProviderInfo = providers[otherProvider] as AuthenticationProviderStatusOk return ( <AnastasisClientFrame hideNav title="Recovery: Select secret"> <div class="columns"> <div class="column"> <div class="box"> - <h1 class="subtitle">Provider {otherProvider}</h1> + <h1 class="subtitle">Provider {otherProviderInfo.business_name}</h1> <div class="block"> - {version === 0 ? <p> - Set to recover the latest version - </p> : <p> - Set to recover the version number {version} - </p>} + {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> @@ -125,13 +190,21 @@ function SelectOtherVersionProviderScreen({ providers, provider, version, onConf <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} + <select + onChange={(e) => setOtherProvider(e.currentTarget.value)} + value={otherProvider} + > + <option key="none" disabled selected value=""> + {" "} + Choose a provider{" "} + </option> + {Object.keys(providers).map((url) => { + const p = providers[url] + if (!("methods" in p)) return null + return <option key={url} value={url}> + {p.business_name} </option> - ))} + })} </select> <div class="icon is-small is-left"> <i class="mdi mdi-earth" /> @@ -140,27 +213,43 @@ function SelectOtherVersionProviderScreen({ providers, provider, version, onConf </div> </div> <div class="container"> - <NumberInput + <PhoneNumberInput label="Version" placeholder="version number to recover" grabFocus - bind={[otherVersion, setOtherVersion]} /> + bind={[otherVersion, setOtherVersion]} + /> </div> </div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={onCancel}>Cancel</button> + <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> + <AsyncButton + class="button" + onClick={() => onConfirm(otherProvider, 0)} + > + Use latest + </AsyncButton> + <AsyncButton + class="button is-info" + onClick={() => + onConfirm(otherProvider, parseInt(otherVersion, 10)) + } + > + Confirm + </AsyncButton> </div> </div> </div> - <div class="column"> - . - </div> </div> - </AnastasisClientFrame> ); - } diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx index cb6561b3f..76d0700db 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,109 +15,63 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { SolveScreen as TestedComponent } from './SolveScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { + ChallengeFeedbackStatus, + RecoveryStates, + ReducerState, +} from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { SolveScreen as TestedComponent } from "./SolveScreen"; export default { - title: 'Pages/recovery/SolveScreen', + title: "Pages/recovery/SolveChallenge/Solve", component: TestedComponent, args: { order: 6, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const NoInformation = createExample(TestedComponent, reducerStatesExample.challengeSolving); +export const NoInformation = createExample( + TestedComponent, + reducerStatesExample.challengeSolving, +); export const NotSupportedChallenge = createExample(TestedComponent, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'does P equals NP?', - type: 'chall-type', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "chall-type", + uuid: "ASDASDSAD!1", + }, + ], policies: [], }, - selected_challenge_uuid: 'ASDASDSAD!1' + selected_challenge_uuid: "ASDASDSAD!1", } as ReducerState); export const MismatchedChallengeId = createExample(TestedComponent, { ...reducerStatesExample.challengeSolving, recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'does P equals NP?', - type: 'chall-type', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'no-no-no' -} as ReducerState); - -export const SmsChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'SMS to 555-5555', - type: 'sms', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const QuestionChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'does P equals NP?', - type: 'question', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const EmailChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'Email to sebasjm@some-domain.com', - type: 'email', - uuid: 'ASDASDSAD!1' - }], - policies: [], - }, - selected_challenge_uuid: 'ASDASDSAD!1' -} as ReducerState); - -export const PostChallenge = createExample(TestedComponent, { - ...reducerStatesExample.challengeSolving, - recovery_information: { - challenges: [{ - cost: 'USD:1', - instructions: 'Letter to address in postal code ABC123', - type: 'post', - uuid: 'ASDASDSAD!1' - }], + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "chall-type", + uuid: "ASDASDSAD!1", + }, + ], policies: [], }, - selected_challenge_uuid: 'ASDASDSAD!1' + selected_challenge_uuid: "no-no-no", } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx index bc1a88db3..b87dad2ce 100644 --- a/packages/anastasis-webui/src/pages/home/SolveScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/SolveScreen.tsx @@ -1,50 +1,132 @@ -import { Fragment, h, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { h, VNode } from "preact"; import { AnastasisClientFrame } from "."; import { ChallengeFeedback, ChallengeFeedbackStatus, - ChallengeInfo, } from "../../../../anastasis-core/lib"; -import { AsyncButton } from "../../components/AsyncButton"; -import { TextInput } from "../../components/fields/TextInput"; +import { Notifications } from "../../components/Notifications"; import { useAnastasisContext } from "../../context/anastasis"; +import { authMethods, KnownAuthMethods } from "./authMethod"; -function SolveOverviewFeedbackDisplay(props: { feedback?: ChallengeFeedback }) { +export function SolveOverviewFeedbackDisplay(props: { + feedback?: ChallengeFeedback; +}): VNode { const { feedback } = props; if (!feedback) { - return null; + return <div />; } switch (feedback.state) { case ChallengeFeedbackStatus.Message: return ( - <div> - <p>{feedback.message}</p> - </div> + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: feedback.message, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.Payment: + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: ( + <span> + To pay you can <a href={feedback.taler_pay_uri}>click here</a> + </span> + ), + }, + ]} + /> ); - case ChallengeFeedbackStatus.Pending: case ChallengeFeedbackStatus.AuthIban: - return null; + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: `Need to send a wire transfer to "${feedback.business_name}"`, + }, + ]} + /> + ); + case ChallengeFeedbackStatus.ServerFailure: + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Server error: Code ${feedback.http_status}`, + description: feedback.error_response, + }, + ]} + /> + ); case ChallengeFeedbackStatus.RateLimitExceeded: - return <div>Rate limit exceeded.</div>; + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `Message from provider`, + description: "There were to many failed attempts.", + }, + ]} + /> + ); case ChallengeFeedbackStatus.Redirect: - return <div>Redirect (FIXME: not supported)</div>; + return ( + <Notifications + notifications={[ + { + type: "INFO", + message: `Message from provider`, + description: ( + <span> + Please visit this link: <a>{feedback.redirect_url}</a> + </span> + ), + }, + ]} + /> + ); case ChallengeFeedbackStatus.Unsupported: - return <div>Challenge not supported by client.</div>; + return ( + <Notifications + notifications={[ + { + type: "ERROR", + message: `This client doesn't support solving this type of challenge`, + description: `Use another version or contact the provider. Type of challenge "${feedback.unsupported_method}"`, + }, + ]} + /> + ); case ChallengeFeedbackStatus.TruthUnknown: - return <div>Truth unknown</div>; - default: return ( - <div> - <pre>{JSON.stringify(feedback)}</pre> - </div> + <Notifications + notifications={[ + { + type: "ERROR", + message: `Provider doesn't recognize the type of challenge`, + description: "Contact the provider for further information", + }, + ]} + /> ); + default: + return <div />; } } export function SolveScreen(): VNode { const reducer = useAnastasisContext(); - const [answer, setAnswer] = useState(""); if (!reducer) { return ( @@ -78,161 +160,54 @@ export function SolveScreen(): VNode { 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> + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={() => reducer.back()}> + Back + </button> + </div> + </AnastasisClientFrame> + ); + } + function SolveNotImplemented(): VNode { + return ( + <AnastasisClientFrame hideNav title="Not implemented"> + <p> + The challenge selected is not supported for this UI. Please update + this version or try using another policy. + </p> + {reducer && ( + <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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; - const challenges: { - [uuid: string]: ChallengeInfo; - } = {}; - for (const ch of chArr) { - challenges[ch.uuid] = ch; - } - const selectedChallenge = challenges[selectedUuid]; - const dialogMap: Record<string, (p: SolveEntryProps) => h.JSX.Element> = { - question: SolveQuestionEntry, - sms: SolveSmsEntry, - email: SolveEmailEntry, - post: SolvePostEntry, - }; - 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(); - } + const selectedChallenge = chArr.find((ch) => ch.uuid === selectedUuid); - return ( - <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> - ); -} + const SolveDialog = + !selectedChallenge || + !authMethods[selectedChallenge.type as KnownAuthMethods] + ? SolveNotImplemented + : authMethods[selectedChallenge.type as KnownAuthMethods].solve ?? + SolveNotImplemented; -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> - ); + return <SolveDialog id={selectedUuid} />; } diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx index 657a2dd74..fcddaf87a 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.stories.tsx @@ -15,24 +15,26 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../utils'; -import { StartScreen as TestedComponent } from './StartScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../utils"; +import { StartScreen as TestedComponent } from "./StartScreen"; export default { - title: 'Pages/StartScreen', + title: "Pages/Start", component: TestedComponent, args: { order: 1, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const InitialState = createExample(TestedComponent, reducerStatesExample.initial);
\ No newline at end of file +export const InitialState = createExample( + TestedComponent, + reducerStatesExample.initial, +); diff --git a/packages/anastasis-webui/src/pages/home/StartScreen.tsx b/packages/anastasis-webui/src/pages/home/StartScreen.tsx index d53df4cae..8b24ef684 100644 --- a/packages/anastasis-webui/src/pages/home/StartScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/StartScreen.tsx @@ -1,27 +1,36 @@ - import { h, VNode } from "preact"; import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function StartScreen(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { - return <div>no reducer in context</div> + return <div>no reducer in context</div>; } return ( <AnastasisClientFrame hideNav title="Home"> <div class="columns"> <div class="column" /> <div class="column is-four-fifths"> - <div class="buttons"> - <button class="button is-success" autoFocus onClick={() => reducer.startBackup()}> - <div class="icon"><i class="mdi mdi-arrow-up" /></div> + <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()}> - <div class="icon"><i class="mdi mdi-arrow-down" /></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> @@ -30,7 +39,6 @@ export function StartScreen(): VNode { <span>Restore a session</span> </button> */} </div> - </div> <div class="column" /> </div> diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx index 7568ccd69..245ad8889 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.stories.tsx @@ -15,29 +15,31 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { ReducerState } from 'anastasis-core'; -import { createExample, reducerStatesExample } from '../../utils'; -import { TruthsPayingScreen as TestedComponent } from './TruthsPayingScreen'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../utils"; +import { TruthsPayingScreen as TestedComponent } from "./TruthsPayingScreen"; export default { - title: 'Pages/backup/__TruthsPayingScreen', + title: "Pages/backup/__TruthsPaying", component: TestedComponent, args: { order: 10, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -export const Example = createExample(TestedComponent, reducerStatesExample.truthsPaying); +export const Example = createExample( + TestedComponent, + reducerStatesExample.truthsPaying, +); export const WithPaytoList = createExample(TestedComponent, { ...reducerStatesExample.truthsPaying, - payments: ['payto://x-taler-bank/bank/account'] + payments: ["payto://x-taler-bank/bank/account"], } as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx index 0b32e0db5..6f95fa93b 100644 --- a/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx +++ b/packages/anastasis-webui/src/pages/home/TruthsPayingScreen.tsx @@ -3,19 +3,19 @@ import { useAnastasisContext } from "../../context/anastasis"; import { AnastasisClientFrame } from "./index"; export function TruthsPayingScreen(): 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.backup_state === undefined) { - return <div>invalid state</div> + if ( + !reducer.currentReducerState || + reducer.currentReducerState.backup_state === undefined + ) { + return <div>invalid state</div>; } const payments = reducer.currentReducerState.payments ?? []; return ( - <AnastasisClientFrame - hideNext={"FIXME"} - title="Backup: Truths Paying" - > + <AnastasisClientFrame hideNext={"FIXME"} title="Backup: Truths Paying"> <p> Some of the providers require a payment to store the encrypted authentication information. diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx index e178a4955..080a7ab31 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,51 +15,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/email', + title: "Pages/backup/AuthorizationMethod/AuthMethods/email", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'email' +const type: KnownAuthMethods = "email"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + 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 WithOneExample = createExample( + TestedComponent[type].setup, + 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Email to sebasjm@email.com", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Email to someone@sebasjm.com", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx index 1a6be1b61..556e3bdbf 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSetup.tsx @@ -1,59 +1,94 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; +import { 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"; +import { AnastasisClientFrame } from "../index"; +import { AuthMethodSetupProps } from "./index"; -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,}))$/ +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 { +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 + 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; + function goNextIfNoErrors(): void { + if (!errors) addEmailAuth(); + } 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. + email. Add the uuid from the challenge </p> <div> <EmailInput label="Email address" error={emailError} + onConfirm={goNextIfNoErrors} placeholder="email@domain.com" - bind={[email, setEmail]} /> + 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>} + {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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addEmailAuth} + > + Add + </button> </span> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx new file mode 100644 index 000000000..729fa8a1b --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.stories.tsx @@ -0,0 +1,90 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/email", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "email"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); + +export const PaymentFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "Email to me@domain.com", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + challenge_feedback: { + "uuid-1": { + state: ChallengeFeedbackStatus.Payment, + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + }, + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx new file mode 100644 index 000000000..e50c3bb20 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodEmailSolve.tsx @@ -0,0 +1,148 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodEmailSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + const [expanded, setExpanded] = useState(false); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Email challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + An email has been sent to "<b>{selectedChallenge.instructions}</b>". The + message has and identification code and recovery code that starts with " + <b>A-</b>". Wait the message to arrive and the enter the recovery code + below. + </p> + {!expanded ? ( + <p> + The identification code in the email should start with " + {selectedUuid.substring(0, 10)}" + <span + class="icon has-tooltip-top" + data-tooltip="click to expand" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + ) : ( + <p> + The identification code in the email is "{selectedUuid}" + <span + class="icon has-tooltip-top" + data-tooltip="click to show less code" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + )} + <TextInput + label="Answer" + grabFocus + onConfirm={onNext} + bind={[answer, setAnswer]} + placeholder="A-1234567812345678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx index 71f618646..c521e18fd 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.stories.tsx @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/camelcase */ /* This file is part of GNU Taler (C) 2021 Taler Systems S.A. @@ -16,50 +15,66 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/IBAN', + title: "Pages/backup/AuthorizationMethod/AuthMethods/IBAN", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'iban' +const type: KnownAuthMethods = "iban"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + 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 - }] -},); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Sebastian", + remove: () => null, + }, + ], + }, +); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Javier", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Wire transfer from QWEASD123123 with holder Sebastian", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx index c9edbfa07..501a40600 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSetup.tsx @@ -1,65 +1,111 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { canonicalJson, encodeCrock, - stringToBytes + stringToBytes, } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useState } from "preact/hooks"; +import { AuthMethodSetupProps } from "."; import { TextInput } from "../../../components/fields/TextInput"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodIbanSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +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 - ) + 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; + function goNextIfNoErrors(): void { + if (!errors) addIbanAuth(); + } 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. + 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]} /> + onConfirm={goNextIfNoErrors} + bind={[name, setName]} + /> <TextInput label="IBAN" placeholder="DE91100000000123456789" - bind={[account, setAccount]} /> + onConfirm={goNextIfNoErrors} + 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>} + {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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addIbanAuth} + > + Add + </button> </span> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx new file mode 100644 index 000000000..cbbc253e9 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/Iban", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "iban"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx new file mode 100644 index 000000000..5cff7bf01 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodIbanSolve.tsx @@ -0,0 +1,112 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodIbanSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="IBAN Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>Send a wire transfer to the address,</p> + <button class="button">Check</button> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx index 0f1c17495..2977586ac 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.stories.tsx @@ -16,51 +16,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Post', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Post", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'post' +const type: KnownAuthMethods = "post"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + 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 WithOneExample = createExample( + TestedComponent[type].setup, + 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code QWE456", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "Letter to address in postal code ABC123", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx index bfeaaa832..04e00500c 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSetup.tsx @@ -1,15 +1,19 @@ -/* eslint-disable @typescript-eslint/camelcase */ import { - canonicalJson, encodeCrock, - stringToBytes + canonicalJson, + encodeCrock, + stringToBytes, } from "@gnu-taler/taler-util"; -import { Fragment, h, VNode } from "preact"; +import { h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; -import { TextInput } from "../../../components/fields/TextInput"; import { AnastasisClientFrame } from ".."; +import { TextInput } from "../../../components/fields/TextInput"; +import { AuthMethodSetupProps } from "./index"; -export function AuthMethodPostSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +export function AuthMethodPostSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [fullName, setFullName] = useState(""); const [street, setStreet] = useState(""); const [city, setCity] = useState(""); @@ -33,68 +37,108 @@ export function AuthMethodPostSetup({ addAuthMethod, cancel, configured }: AuthM }); }; - 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 - ) - ) - ) - ) + 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; + + function goNextIfNoErrors(): void { + if (!errors) addPostAuth(); + } 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. + 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]} + onConfirm={goNextIfNoErrors} /> </div> <div> <TextInput + onConfirm={goNextIfNoErrors} label="Street" bind={[street, setStreet]} /> </div> <div> <TextInput - label="City" bind={[city, setCity]} + onConfirm={goNextIfNoErrors} + label="City" + bind={[city, setCity]} /> </div> <div> <TextInput - label="Postal Code" bind={[postcode, setPostcode]} + onConfirm={goNextIfNoErrors} + label="Postal Code" + bind={[postcode, setPostcode]} /> </div> <div> <TextInput + onConfirm={goNextIfNoErrors} 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> + {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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addPostAuth} + > + Add + </button> </span> </div> </AnastasisClientFrame> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx new file mode 100644 index 000000000..3b67ee884 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/post", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "post"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx new file mode 100644 index 000000000..1bbbbfc03 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodPostSolve.tsx @@ -0,0 +1,117 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodPostSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Postal Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>Wait for the answer</p> + <TextInput + onConfirm={onNext} + label="Answer" + grabFocus + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx index 3ba4a84ca..991301cbf 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.stories.tsx @@ -16,51 +16,69 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Question', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Question", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'question' +const type: KnownAuthMethods = "question"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + 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 WithOneExample = createExample( + TestedComponent[type].setup, + 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "Does P equal NP?", + remove: () => null, + }, + { + challenge: "asd", + type, + instructions: + "Are continuous groups automatically differential groups?", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx index 04fa00d59..19260c4ff 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSetup.tsx @@ -1,33 +1,39 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "./index"; import { AnastasisClientFrame } from "../index"; import { TextInput } from "../../../components/fields/TextInput"; -export function AuthMethodQuestionSetup({ cancel, addAuthMethod, configured }: AuthMethodSetupProps): VNode { +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 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 - ) + const errors = !questionText + ? "Add your security question" + : !answerText + ? "Add the answer to your question" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addQuestionAuth(); + } return ( <AnastasisClientFrame hideNav title="Add Security Question"> <div> <p> - For2 security question authentication, you need to provide a question + 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. @@ -36,36 +42,67 @@ export function AuthMethodQuestionSetup({ cancel, addAuthMethod, configured }: A <TextInput label="Security question" grabFocus + onConfirm={goNextIfNoErrors} placeholder="Your question" - bind={[questionText, setQuestionText]} /> + bind={[questionText, setQuestionText]} + /> </div> <div> <TextInput label="Answer" + onConfirm={goNextIfNoErrors} placeholder="Your answer" bind={[answerText, setAnswerText]} /> </div> - <div style={{ marginTop: '2em', display: 'flex', justifyContent: 'space-between' }}> - <button class="button" onClick={cancel}>Cancel</button> + <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> + <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>} + {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 > + </AnastasisClientFrame> ); } diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx new file mode 100644 index 000000000..1fa9fd6ec --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.stories.tsx @@ -0,0 +1,258 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/question", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "question"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); + +export const MessageFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Message, + message: "Challenge should be solved", + }, + }, +} as ReducerState); + +export const ServerFailureFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.ServerFailure, + http_status: 500, + error_response: "Couldn't connect to mysql", + }, + }, + } as ReducerState, +); + +export const RedirectFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Redirect, + http_status: 302, + redirect_url: "http://video.taler.net", + }, + }, +} as ReducerState); + +export const MessageRateLimitExceededFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.RateLimitExceeded, + }, + }, + } as ReducerState, +); + +export const UnsupportedFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Unsupported, + http_status: 500, + unsupported_method: "Question", + }, + }, +} as ReducerState); + +export const TruthUnknownFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.TruthUnknown, + }, + }, +} as ReducerState); + +export const AuthIbanFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.AuthIban, + challenge_amount: "EUR:1", + credit_iban: "DE12345789000", + business_name: "Data Loss Incorporated", + wire_transfer_subject: "Anastasis 987654321", + answer_code: 987654321, + // Fields that follow are only for compatibility with C reducer, + // will be removed eventually, + details: { + business_name: "foo", + challenge_amount: "foo", + credit_iban: "foo", + wire_transfer_subject: "foo", + }, + method: "iban", + }, + }, +} as ReducerState); + +export const PaymentFeedback = createExample(TestedComponent[type].solve, { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "ASDASDSAD!1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "ASDASDSAD!1", + challenge_feedback: { + "ASDASDSAD!1": { + state: ChallengeFeedbackStatus.Payment, + taler_pay_uri: "taler://pay/...", + provider: "https://localhost:8080/", + payment_secret: "3P4561HAMHRRYEYD6CM6J7TS5VTD5SR2K2EXJDZEFSX92XKHR4KG", + }, + }, +} as ReducerState); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx new file mode 100644 index 000000000..2636ca47c --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodQuestionSolve.tsx @@ -0,0 +1,121 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodQuestionSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Question challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + In this challenge you need to provide the answer for the next question: + </p> + <pre>{selectedChallenge.instructions}</pre> + <p>Type the answer below</p> + <TextInput + label="Answer" + onConfirm={onNext} + grabFocus + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx index ae8297ef7..3a44c7ad0 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.stories.tsx @@ -16,51 +16,67 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/Sms', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Sms", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'sms' +const type: KnownAuthMethods = "sms"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + 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 WithOneExample = createExample( + TestedComponent[type].setup, + 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: "SMS to +11-1234-2345", + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: "SMS to +11-5555-2345", + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx index 9e85af2b2..e70b2a53b 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSetup.tsx @@ -1,15 +1,15 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +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 { AuthMethodSetupProps } from "."; +import { PhoneNumberInput } from "../../../components/fields/NumberInput"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMethodSetupProps): VNode { +export function AuthMethodSmsSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [mobileNumber, setMobileNumber] = useState(""); const addSmsAuth = (): void => { addAuthMethod({ @@ -24,7 +24,10 @@ export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMe useLayoutEffect(() => { inputRef.current?.focus(); }, []); - const errors = !mobileNumber ? 'Add a mobile number' : undefined + const errors = !mobileNumber ? "Add a mobile number" : undefined; + function goNextIfNoErrors(): void { + if (!errors) addSmsAuth(); + } return ( <AnastasisClientFrame hideNav title="Add SMS authentication"> <div> @@ -34,27 +37,57 @@ export function AuthMethodSmsSetup({ addAuthMethod, cancel, configured }: AuthMe receive via SMS. </p> <div class="container"> - <NumberInput + <PhoneNumberInput label="Mobile number" placeholder="Your mobile number" + onConfirm={goNextIfNoErrors} grabFocus - bind={[mobileNumber, setMobileNumber]} /> + 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> + {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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addSmsAuth} + > + Add + </button> </span> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx new file mode 100644 index 000000000..e8961cccf --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/sms", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "sms"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "SMS to +54 11 2233 4455", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx new file mode 100644 index 000000000..3370c76d0 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodSmsSolve.tsx @@ -0,0 +1,148 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodSmsSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState("A-"); + + const [expanded, setExpanded] = useState(false); + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="SMS Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p> + An sms has been sent to "<b>{selectedChallenge.instructions}</b>". The + message has and identification code and recovery code that starts with " + <b>A-</b>". Wait the message to arrive and the enter the recovery code + below. + </p> + {!expanded ? ( + <p> + The identification code in the SMS should start with " + {selectedUuid.substring(0, 10)}" + <span + class="icon has-tooltip-top" + data-tooltip="click to expand" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + ) : ( + <p> + The identification code in the SMS is "{selectedUuid}" + <span + class="icon has-tooltip-top" + data-tooltip="click to show less code" + onClick={() => setExpanded((e) => !e)} + > + <i class="mdi mdi-information" /> + </span> + </p> + )} + <TextInput + label="Answer" + grabFocus + onConfirm={onNext} + bind={[answer, setAnswer]} + placeholder="A-1234567812345678" + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx index 4e46b600e..bc4628828 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.stories.tsx @@ -16,49 +16,65 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; export default { - title: 'Pages/backup/authMethods/TOTP', + title: "Pages/backup/AuthorizationMethod/AuthMethods/TOTP", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'totp' +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 - }] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); +export const WithOneExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis"', + remove: () => null, + }, + ], + }, +); +export const WithMoreExample = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis1"', + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: 'Enter 8 digits code for "Anastasis2"', + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx index fd0bd0224..6b0dd7a79 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSetup.tsx @@ -1,40 +1,46 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +import { encodeCrock, stringToBytes } from "@gnu-taler/taler-util"; import { h, VNode } from "preact"; import { useMemo, useState } from "preact/hooks"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +import { AuthMethodSetupProps } from "./index"; 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 { +export function AuthMethodTotpSetup({ + addAuthMethod, + cancel, + configured, +}: AuthMethodSetupProps): VNode { const [name, setName] = useState("anastasis"); const [test, setTest] = useState(""); - const digits = 8 + const digits = 8; const secretKey = useMemo(() => { - const array = new Uint8Array(32) - return window.crypto.getRandomValues(array) - }, []) + const array = new Uint8Array(32); + return window.crypto.getRandomValues(array); + }, []); const secret32 = base32enc(secretKey); - const totpURL = `otpauth://totp/${name}?digits=${digits}&secret=${secret32}` + 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 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 - ); + const errors = !name + ? "The TOTP name is missing" + : !testCodeMatches + ? "The test code doesnt match" + : undefined; + function goNextIfNoErrors(): void { + if (!errors) addTotpAuth(); + } return ( <AnastasisClientFrame hideNav title="Add TOTP authentication"> <p> @@ -43,10 +49,7 @@ export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthM 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]} /> + <TextInput label="TOTP Name" grabFocus bind={[name, setName]} /> </div> <div style={{ height: 300 }}> <QR text={totpURL} /> @@ -56,23 +59,53 @@ export function AuthMethodTotpSetup({ addAuthMethod, cancel, configured }: AuthM </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>} + onConfirm={goNextIfNoErrors} + 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> + <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> + <button + class="button is-info" + disabled={errors !== undefined} + onClick={addTotpAuth} + > + Add + </button> </span> </div> </div> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx new file mode 100644 index 000000000..8743c5a73 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/totp", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "totp"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx new file mode 100644 index 000000000..347f9bf03 --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodTotpSolve.tsx @@ -0,0 +1,118 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodTotpSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="TOTP Challenge"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>enter the totp solution</p> + <TextInput + label="Answer" + onConfirm={onNext} + grabFocus + bind={[answer, setAnswer]} + /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} +// NKE8 VD857T X033X6RG WEGPYP6D70 Q7YE XN8D2 ZN79SCN 231B4QK0 diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx index 3c4c7bf39..4aad0a097 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.stories.tsx @@ -16,51 +16,68 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample, reducerStatesExample } from '../../../utils'; -import { authMethods as TestedComponent, KnownAuthMethods } from './index'; -import logoImage from '../../../assets/logo.jpeg' +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', + title: "Pages/backup/AuthorizationMethod/AuthMethods/Video", component: TestedComponent, args: { order: 5, }, argTypes: { - onUpdate: { action: 'onUpdate' }, - onBack: { action: 'onBack' }, + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, }, }; -const type: KnownAuthMethods = 'video' +const type: KnownAuthMethods = "video"; -export const Empty = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [] -}); +export const Empty = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [], + }, +); -export const WithOneExample = createExample(TestedComponent[type].screen, reducerStatesExample.authEditing, { - configured: [{ - challenge: 'qwe', - type, - instructions: logoImage, - remove: () => null - }] -}); +export const WithOneExample = createExample( + TestedComponent[type].setup, + 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 - }] -}); +export const WithMoreExamples = createExample( + TestedComponent[type].setup, + reducerStatesExample.authEditing, + { + configured: [ + { + challenge: "qwe", + type, + instructions: logoImage, + remove: () => null, + }, + { + challenge: "qwe", + type, + instructions: logoImage, + remove: () => null, + }, + ], + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx index 8be999b3f..04a129c4a 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSetup.tsx @@ -1,54 +1,90 @@ -/* eslint-disable @typescript-eslint/camelcase */ -import { - encodeCrock, - stringToBytes -} from "@gnu-taler/taler-util"; +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 { AuthMethodSetupProps } from "./index"; import { AnastasisClientFrame } from "../index"; -export function AuthMethodVideoSetup({cancel, addAuthMethod, configured}: AuthMethodSetupProps): VNode { +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', + instructions: "Join a video call", challenge: encodeCrock(stringToBytes(image)), }, - }) + }); }; + function goNextIfNoErrors(): void { + addVideoAuth(); + } 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. + 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'}}> + <div style={{ textAlign: "center" }}> <ImageInput label="Choose photograph" grabFocus - bind={[image, setImage]} /> + onConfirm={goNextIfNoErrors} + bind={[image, setImage]} + /> </div> - {configured.length > 0 && <section class="section"> + {configured.length > 0 && ( + <section class="section"> + <div class="block">Your photographs:</div> <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> + 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> + </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 + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={cancel}> + Cancel + </button> + <button class="button is-info" onClick={addVideoAuth}> + Add + </button> </div> </div> </AnastasisClientFrame> diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx new file mode 100644 index 000000000..7c5511c5a --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.stories.tsx @@ -0,0 +1,60 @@ +/* + 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 { ChallengeFeedbackStatus, ReducerState } from "anastasis-core"; +import { createExample, reducerStatesExample } from "../../../utils"; +import { authMethods as TestedComponent, KnownAuthMethods } from "./index"; + +export default { + title: "Pages/recovery/SolveChallenge/AuthMethods/video", + component: TestedComponent, + args: { + order: 5, + }, + argTypes: { + onUpdate: { action: "onUpdate" }, + onBack: { action: "onBack" }, + }, +}; + +const type: KnownAuthMethods = "video"; + +export const WithoutFeedback = createExample( + TestedComponent[type].solve, + { + ...reducerStatesExample.challengeSolving, + recovery_information: { + challenges: [ + { + cost: "USD:1", + instructions: "does P equals NP?", + type: "question", + uuid: "uuid-1", + }, + ], + policies: [], + }, + selected_challenge_uuid: "uuid-1", + } as ReducerState, + { + id: "uuid-1", + }, +); diff --git a/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx new file mode 100644 index 000000000..efadb9a9a --- /dev/null +++ b/packages/anastasis-webui/src/pages/home/authMethod/AuthMethodVideoSolve.tsx @@ -0,0 +1,112 @@ +import { ChallengeFeedbackStatus, ChallengeInfo } from "anastasis-core"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/AsyncButton"; +import { TextInput } from "../../../components/fields/TextInput"; +import { useAnastasisContext } from "../../../context/anastasis"; +import { AnastasisClientFrame } from "../index"; +import { SolveOverviewFeedbackDisplay } from "../SolveScreen"; +import { AuthMethodSolveProps } from "./index"; + +export function AuthMethodVideoSolve({ id }: AuthMethodSolveProps): VNode { + const [answer, setAnswer] = useState(""); + + const reducer = useAnastasisContext(); + if (!reducer) { + return ( + <AnastasisClientFrame hideNav title="Recovery problem"> + <div>no reducer in context</div> + </AnastasisClientFrame> + ); + } + 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 ( + <AnastasisClientFrame + hideNext="Recovery document not found" + title="Recovery problem" + > + <div>no recovery information found</div> + </AnastasisClientFrame> + ); + } + if (!reducer.currentReducerState.selected_challenge_uuid) { + 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 selectedUuid = reducer.currentReducerState.selected_challenge_uuid; + const challenges: { + [uuid: string]: ChallengeInfo; + } = {}; + for (const ch of chArr) { + challenges[ch.uuid] = ch; + } + const selectedChallenge = challenges[selectedUuid]; + const feedback = challengeFeedback[selectedUuid]; + + async function onNext(): Promise<void> { + return reducer?.transition("solve_challenge", { answer }); + } + function onCancel(): void { + reducer?.back(); + } + + const shouldHideConfirm = + feedback?.state === ChallengeFeedbackStatus.RateLimitExceeded || + feedback?.state === ChallengeFeedbackStatus.Redirect || + feedback?.state === ChallengeFeedbackStatus.Unsupported || + feedback?.state === ChallengeFeedbackStatus.TruthUnknown; + + return ( + <AnastasisClientFrame hideNav title="Add email authentication"> + <SolveOverviewFeedbackDisplay feedback={feedback} /> + <p>You are gonna be called to check your identity</p> + <TextInput label="Answer" grabFocus bind={[answer, setAnswer]} /> + + <div + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button class="button" onClick={onCancel}> + Cancel + </button> + {!shouldHideConfirm && ( + <AsyncButton class="button is-info" onClick={onNext}> + Confirm + </AsyncButton> + )} + </div> + </AnastasisClientFrame> + ); +} diff --git a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx index 7b0cce883..b4f649488 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/index.tsx +++ b/packages/anastasis-webui/src/pages/home/authMethod/index.tsx @@ -1,25 +1,60 @@ +import { AuthMethod } from "anastasis-core"; import { h, VNode } from "preact"; -import { AuthMethodSetupProps } from "../AuthenticationEditorScreen"; +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"; +import { AuthMethodEmailSetup as EmailSetup } from "./AuthMethodEmailSetup"; +import { AuthMethodEmailSolve as EmailSolve } from "./AuthMethodEmailSolve"; +import { AuthMethodIbanSetup as IbanSetup } from "./AuthMethodIbanSetup"; +import { AuthMethodPostSetup as PostalSetup } from "./AuthMethodPostSetup"; +import { AuthMethodQuestionSetup as QuestionSetup } from "./AuthMethodQuestionSetup"; +import { AuthMethodSmsSetup as SmsSetup } from "./AuthMethodSmsSetup"; +import { AuthMethodTotpSetup as TotpSetup } from "./AuthMethodTotpSetup"; +import { AuthMethodVideoSetup as VideoSetup } from "./AuthMethodVideoSetup"; -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'; +import { AuthMethodIbanSolve as IbanSolve } from "./AuthMethodIbanSolve"; +import { AuthMethodPostSolve as PostalSolve } from "./AuthMethodPostSolve"; +import { AuthMethodQuestionSolve as QuestionSolve } from "./AuthMethodQuestionSolve"; +import { AuthMethodSmsSolve as SmsSolve } from "./AuthMethodSmsSolve"; +import { AuthMethodTotpSolve as TotpSolve } from "./AuthMethodTotpSolve"; +import { AuthMethodVideoSolve as VideoSolve } from "./AuthMethodVideoSolve"; + +export type AuthMethodWithRemove = AuthMethod & { remove: () => void }; + +export interface AuthMethodSetupProps { + method: string; + addAuthMethod: (x: any) => void; + configured: AuthMethodWithRemove[]; + cancel: () => void; +} + +export interface AuthMethodSolveProps { + id: string; +} interface AuthMethodConfiguration { icon: VNode; label: string; - screen: (props: AuthMethodSetupProps) => VNode; + setup: (props: AuthMethodSetupProps) => VNode; + solve: (props: AuthMethodSolveProps) => VNode; skip?: boolean; } -export type KnownAuthMethods = "sms" | "email" | "post" | "question" | "video" | "totp" | "iban"; +// export type KnownAuthMethods = "sms" | "email" | "post" | "question" | "video" | "totp" | "iban"; + +const ALL_METHODS = [ + "sms", + "email", + "post", + "question", + "video", + "totp", + "iban", +] as const; +export type KnownAuthMethods = typeof ALL_METHODS[number]; +export function isKnownAuthMethods(value: string): value is KnownAuthMethods { + return ALL_METHODS.includes(value as KnownAuthMethods); +} type KnowMethodConfig = { [name in KnownAuthMethods]: AuthMethodConfiguration; @@ -29,41 +64,44 @@ export const authMethods: KnowMethodConfig = { question: { icon: <img src={questionIcon} />, label: "Question", - screen: QuestionScreen + setup: QuestionSetup, + solve: QuestionSolve, }, sms: { icon: <img src={smsIcon} />, label: "SMS", - screen: SmsScreen + setup: SmsSetup, + solve: SmsSolve, }, email: { icon: <i class="mdi mdi-email" />, label: "Email", - screen: EmailScreen - + setup: EmailSetup, + solve: EmailSolve, }, iban: { icon: <i class="mdi mdi-bank" />, label: "IBAN", - screen: IbanScreen - + setup: IbanSetup, + solve: IbanSolve, }, post: { icon: <img src={postalIcon} />, label: "Physical mail", - screen: PostalScreen - + setup: PostalSetup, + solve: PostalSolve, }, totp: { icon: <i class="mdi mdi-devices" />, label: "TOTP", - screen: TotpScreen - + setup: TotpSetup, + solve: TotpSolve, }, video: { icon: <img src={videoIcon} />, label: "Video", - screen: VideScreen, - skip: true, - } -}
\ No newline at end of file + setup: VideoSetup, + solve: VideoSolve, + skip: true, + }, +}; diff --git a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts index 0bc3feaf8..c2288671c 100644 --- a/packages/anastasis-webui/src/pages/home/authMethod/totp.ts +++ b/packages/anastasis-webui/src/pages/home/authMethod/totp.ts @@ -1,54 +1,61 @@ /* eslint-disable @typescript-eslint/camelcase */ -import jssha from 'jssha' +import jssha from "jssha"; -const SEARCH_RANGE = 16 -const timeStep = 30 +const SEARCH_RANGE = 16; +const timeStep = 30; -export function computeTOTPandCheck(secretKey: Uint8Array, digits: number, code: number): boolean { - const now = new Date().getTime() +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' } }); + const hmacSha = new jssha("SHA-1", "HEX", { + hmacKey: { value: secretKey, format: "UINT8ARRAY" }, + }); hmacSha.update(movingFactor); - const hmac_text = hmacSha.getHMAC('UINT8ARRAY'); + const hmac_text = hmacSha.getHMAC("UINT8ARRAY"); - const offset = (hmac_text[hmac_text.length - 1] & 0xf) + 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) + 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 + if (otp == code) return true; } - return false + return false; } -const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split('') +const encTable__ = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567".split(""); export function base32enc(buffer: Uint8Array): string { - let rpos = 0 - let bits = 0 - let vbit = 0 + let rpos = 0; + let bits = 0; + let vbit = 0; - let result = "" - while ((rpos < buffer.length) || (vbit > 0)) { - if ((rpos < buffer.length) && (vbit < 5)) { + 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); + bits <<= 5 - vbit; vbit = 5; } result += encTable__[(bits >> (vbit - 5)) & 31]; vbit -= 5; } - return result + return result; } // const array = new Uint8Array(256) diff --git a/packages/anastasis-webui/src/pages/home/index.tsx b/packages/anastasis-webui/src/pages/home/index.tsx index 07bc7c604..d83442e62 100644 --- a/packages/anastasis-webui/src/pages/home/index.tsx +++ b/packages/anastasis-webui/src/pages/home/index.tsx @@ -1,25 +1,22 @@ +import { BackupStates, RecoveryStates } from "anastasis-core"; import { - BackupStates, - RecoveryStates, - ReducerStateBackup, - ReducerStateRecovery -} from "anastasis-core"; -import { - ComponentChildren, Fragment, + ComponentChildren, + Fragment, FunctionalComponent, h, - VNode + VNode, } from "preact"; -import { - useErrorBoundary -} from "preact/hooks"; +import { 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 { + AnastasisProvider, + useAnastasisContext, +} from "../../context/anastasis"; import { AnastasisReducerApi, - useAnastasisReducer + useAnastasisReducer, } from "../../hooks/use-anastasis-reducer"; import { AttributeEntryScreen } from "./AttributeEntryScreen"; import { AuthenticationEditorScreen } from "./AuthenticationEditorScreen"; @@ -51,7 +48,11 @@ export function withProcessLabel( } interface AnastasisClientFrameProps { - onNext?(): void; + onNext?(): Promise<void>; + /** + * Override for the "back" functionality. + */ + onBack?(): Promise<void>; title: string; children: ComponentChildren; /** @@ -118,9 +119,27 @@ export function AnastasisClientFrame(props: AnastasisClientFrameProps): VNode { <section class="section is-main-section"> {props.children} {!props.hideNav ? ( - <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 + style={{ + marginTop: "2em", + display: "flex", + justifyContent: "space-between", + }} + > + <button + class="button" + onClick={() => (props.onBack ?? reducer.back)()} + > + Back + </button> + <AsyncButton + class="button is-info" + data-tooltip={props.hideNext} + onClick={next} + disabled={props.hideNext !== undefined} + > + Next + </AsyncButton> </div> ) : null} </section> @@ -141,7 +160,7 @@ const AnastasisClient: FunctionalComponent = () => { }; function AnastasisClientImpl(): VNode { - const reducer = useAnastasisContext() + const reducer = useAnastasisContext(); if (!reducer) { return <p>Fatal: Reducer must be in context.</p>; } @@ -157,27 +176,19 @@ function AnastasisClientImpl(): VNode { state.backup_state === BackupStates.CountrySelecting || state.recovery_state === RecoveryStates.CountrySelecting ) { - return ( - <ContinentSelectionScreen /> - ); + return <ContinentSelectionScreen />; } if ( state.backup_state === BackupStates.UserAttributesCollecting || state.recovery_state === RecoveryStates.UserAttributesCollecting ) { - return ( - <AttributeEntryScreen /> - ); + return <AttributeEntryScreen />; } if (state.backup_state === BackupStates.AuthenticationsEditing) { - return ( - <AuthenticationEditorScreen /> - ); + return <AuthenticationEditorScreen />; } if (state.backup_state === BackupStates.PoliciesReviewing) { - return ( - <ReviewPoliciesScreen /> - ); + return <ReviewPoliciesScreen />; } if (state.backup_state === BackupStates.SecretEditing) { return <SecretEditorScreen />; @@ -196,15 +207,11 @@ function AnastasisClientImpl(): VNode { } if (state.recovery_state === RecoveryStates.SecretSelecting) { - return ( - <SecretSelectionScreen /> - ); + return <SecretSelectionScreen />; } if (state.recovery_state === RecoveryStates.ChallengeSelecting) { - return ( - <ChallengeOverviewScreen /> - ); + return <ChallengeOverviewScreen />; } if (state.recovery_state === RecoveryStates.ChallengeSolving) { @@ -212,9 +219,7 @@ function AnastasisClientImpl(): VNode { } if (state.recovery_state === RecoveryStates.RecoveryFinished) { - return ( - <RecoveryFinishedScreen /> - ); + return <RecoveryFinishedScreen />; } if (state.recovery_state === RecoveryStates.ChallengePaying) { return <ChallengePayingScreen />; @@ -224,7 +229,9 @@ function AnastasisClientImpl(): VNode { <AnastasisClientFrame hideNav title="Bug"> <p>Bug: Unknown state.</p> <div class="buttons is-right"> - <button class="button" onClick={() => reducer.reset()}>Reset</button> + <button class="button" onClick={() => reducer.reset()}> + Reset + </button> </div> </AnastasisClientFrame> ); @@ -236,11 +243,17 @@ function AnastasisClientImpl(): VNode { function ErrorBanner(): VNode | null { const reducer = useAnastasisContext(); if (!reducer || !reducer.currentError) return null; - return (<Notifications removeNotification={reducer.dismissError} notifications={[{ - type: "ERROR", - message: `Error code: ${reducer.currentError.code}`, - description: reducer.currentError.hint - }]} /> + return ( + <Notifications + removeNotification={reducer.dismissError} + notifications={[ + { + type: "ERROR", + message: `Error code: ${reducer.currentError.code}`, + description: reducer.currentError.hint, + }, + ]} + /> ); } diff --git a/packages/anastasis-webui/src/pages/notfound/index.tsx b/packages/anastasis-webui/src/pages/notfound/index.tsx index 4e74d1d9f..bb22429b0 100644 --- a/packages/anastasis-webui/src/pages/notfound/index.tsx +++ b/packages/anastasis-webui/src/pages/notfound/index.tsx @@ -1,16 +1,16 @@ -import { FunctionalComponent, h } from 'preact'; -import { Link } from 'preact-router/match'; +import { FunctionalComponent, h } from "preact"; +import { Link } from "preact-router/match"; const Notfound: FunctionalComponent = () => { - return ( - <div> - <h1>Error 404</h1> - <p>That page doesn't exist.</p> - <Link href="/"> - <h4>Back to Home</h4> - </Link> - </div> - ); + return ( + <div> + <h1>Error 404</h1> + <p>That page doesn't exist.</p> + <Link href="/"> + <h4>Back to Home</h4> + </Link> + </div> + ); }; export default Notfound; diff --git a/packages/anastasis-webui/src/pages/profile/index.tsx b/packages/anastasis-webui/src/pages/profile/index.tsx index 859a83ed4..bcd26370e 100644 --- a/packages/anastasis-webui/src/pages/profile/index.tsx +++ b/packages/anastasis-webui/src/pages/profile/index.tsx @@ -1,43 +1,42 @@ -import { FunctionalComponent, h } from 'preact'; -import { useEffect, useState } from 'preact/hooks'; +import { FunctionalComponent, h } from "preact"; +import { useEffect, useState } from "preact/hooks"; interface Props { - user: string; + user: string; } const Profile: FunctionalComponent<Props> = (props: Props) => { - const { user } = props; - const [time, setTime] = useState<number>(Date.now()); - const [count, setCount] = useState<number>(0); - - // gets called when this route is navigated to - useEffect(() => { - const timer = window.setInterval(() => setTime(Date.now()), 1000); - - // gets called just before navigating away from the route - return (): void => { - clearInterval(timer); - }; - }, []); - - // update the current time - const increment = (): void => { - setCount(count + 1); + const { user } = props; + const [time, setTime] = useState<number>(Date.now()); + const [count, setCount] = useState<number>(0); + + // gets called when this route is navigated to + useEffect(() => { + const timer = window.setInterval(() => setTime(Date.now()), 1000); + + // gets called just before navigating away from the route + return (): void => { + clearInterval(timer); }; + }, []); + + // update the current time + const increment = (): void => { + setCount(count + 1); + }; - return ( - <div> - <h1>Profile: {user}</h1> - <p>This is the user profile for a user named {user}.</p> + return ( + <div> + <h1>Profile: {user}</h1> + <p>This is the user profile for a user named {user}.</p> - <div>Current time: {new Date(time).toLocaleString()}</div> + <div>Current time: {new Date(time).toLocaleString()}</div> - <p> - <button onClick={increment}>Click Me</button> Clicked {count}{' '} - times. - </p> - </div> - ); + <p> + <button onClick={increment}>Click Me</button> Clicked {count} times. + </p> + </div> + ); }; export default Profile; diff --git a/packages/anastasis-webui/src/scss/DurationPicker.scss b/packages/anastasis-webui/src/scss/DurationPicker.scss index a35575324..aa75b9916 100644 --- a/packages/anastasis-webui/src/scss/DurationPicker.scss +++ b/packages/anastasis-webui/src/scss/DurationPicker.scss @@ -1,4 +1,3 @@ - .rdp-picker { display: flex; height: 175px; diff --git a/packages/anastasis-webui/src/scss/_aside.scss b/packages/anastasis-webui/src/scss/_aside.scss index c9332b252..11809990b 100644 --- a/packages/anastasis-webui/src/scss/_aside.scss +++ b/packages/anastasis-webui/src/scss/_aside.scss @@ -19,37 +19,35 @@ * @author Sebastian Javier Marchano (sebasjm) */ -@include desktop { - html { - &.has-aside-left { - &.has-aside-expanded { - nav.navbar, - body { - padding-left: $aside-width; - } - } - aside.is-placed-left { - display: block; +html { + &.has-aside-left { + &.has-aside-expanded { + nav.navbar, + body { + padding-left: $aside-width; } } + aside.is-placed-left { + display: block; + } } +} - aside.aside.is-expanded { - width: $aside-width; +aside.aside.is-expanded { + width: $aside-width; - .menu-list { - @include icon-with-update-mark($aside-icon-width); + .menu-list { + @include icon-with-update-mark($aside-icon-width); - span.menu-item-label { - display: inline-block; - } + span.menu-item-label { + display: inline-block; + } - li.is-active { - ul { - display: block; - } - background-color: $body-background-color; + li.is-active { + ul { + display: block; } + background-color: $body-background-color; } } } @@ -128,59 +126,3 @@ aside.aside { margin-bottom: $default-padding * 0.5; } } - -@include touch { - nav.navbar { - @include transition(margin-left); - } - aside.aside { - @include transition(left); - } - html.has-aside-mobile-transition { - body { - overflow-x: hidden; - } - body, - nav.navbar { - width: 100vw; - } - aside.aside { - width: $aside-mobile-width; - display: block; - left: $aside-mobile-width * -1; - - .image { - img { - max-width: $aside-mobile-width * 0.33; - } - } - - .menu-list { - li.is-active { - ul { - display: block; - } - background-color: $body-background-color; - } - li { - @include icon-with-update-mark($aside-icon-width); - margin-top: 8px; - margin-bottom: 8px; - } - a { - span.menu-item-label { - display: inline-block; - } - } - } - } - } - div.has-aside-mobile-expanded { - nav.navbar { - margin-left: $aside-mobile-width; - } - aside.aside { - left: 0; - } - } -} diff --git a/packages/anastasis-webui/src/scss/_card.scss b/packages/anastasis-webui/src/scss/_card.scss index b2eec27a1..3f71aeb6a 100644 --- a/packages/anastasis-webui/src/scss/_card.scss +++ b/packages/anastasis-webui/src/scss/_card.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -39,7 +39,7 @@ &.is-card-widget { .card-content { - padding: $default-padding * .5; + padding: $default-padding * 0.5; } } diff --git a/packages/anastasis-webui/src/scss/_custom-calendar.scss b/packages/anastasis-webui/src/scss/_custom-calendar.scss index bff68cf79..e0334b62d 100644 --- a/packages/anastasis-webui/src/scss/_custom-calendar.scss +++ b/packages/anastasis-webui/src/scss/_custom-calendar.scss @@ -16,31 +16,30 @@ :root { --primary-color: #3298dc; - - --primary-text-color-dark: rgba(0,0,0,.87); - --secondary-text-color-dark: rgba(0,0,0,.57); - --disabled-text-color-dark: rgba(0,0,0,.13); - - --primary-text-color-light: rgba(255,255,255,.87); - --secondary-text-color-light: rgba(255,255,255,.57); - --disabled-text-color-light: rgba(255,255,255,.13); - - --font-stack: 'Roboto', 'Helvetica Neue', Helvetica, Arial, sans-serif; - + + --primary-text-color-dark: rgba(0, 0, 0, 0.87); + --secondary-text-color-dark: rgba(0, 0, 0, 0.57); + --disabled-text-color-dark: rgba(0, 0, 0, 0.13); + + --primary-text-color-light: rgba(255, 255, 255, 0.87); + --secondary-text-color-light: rgba(255, 255, 255, 0.57); + --disabled-text-color-light: rgba(255, 255, 255, 0.13); + + --font-stack: "Roboto", "Helvetica Neue", Helvetica, Arial, sans-serif; + --primary-card-color: #fff; --primary-background-color: #f2f2f2; - + --box-shadow-lvl-1: 0 1px 3px rgba(0, 0, 0, 0.12), - 0 1px 2px rgba(0, 0, 0, 0.24); + 0 1px 2px rgba(0, 0, 0, 0.24); --box-shadow-lvl-2: 0 3px 6px rgba(0, 0, 0, 0.16), - 0 3px 6px rgba(0, 0, 0, 0.23); + 0 3px 6px rgba(0, 0, 0, 0.23); --box-shadow-lvl-3: 0 10px 20px rgba(0, 0, 0, 0.19), - 0 6px 6px rgba(0, 0, 0, 0.23); + 0 6px 6px rgba(0, 0, 0, 0.23); --box-shadow-lvl-4: 0 14px 28px rgba(0, 0, 0, 0.25), - 0 10px 10px rgba(0, 0, 0, 0.22); + 0 10px 10px rgba(0, 0, 0, 0.22); } - .home .datePicker div { margin-top: 0px; margin-bottom: 0px; @@ -56,7 +55,7 @@ width: 90vw; max-width: 448px; transform-origin: top left; - transition: transform .22s ease-in-out, opacity .22s ease-in-out; + transition: transform 0.22s ease-in-out, opacity 0.22s ease-in-out; top: 50%; left: 50%; opacity: 0; @@ -67,7 +66,7 @@ opacity: 1; transform: scale(1) translate(-50%, -50%); } - + .datePicker--titles { border-top-left-radius: 3px; border-top-right-radius: 3px; @@ -75,7 +74,8 @@ height: 100px; background: var(--primary-color); - h2, h3 { + h2, + h3 { cursor: pointer; color: #fff; line-height: 1; @@ -85,7 +85,7 @@ } h3 { - color: rgba(255,255,255,.57); + color: rgba(255, 255, 255, 0.57); font-size: 18px; padding-bottom: 2px; } @@ -114,13 +114,13 @@ font-size: 26px; user-select: none; border-radius: 50%; - + &:hover { background: var(--disabled-text-color-dark); } } } - + .datePicker--scroll { overflow-y: auto; max-height: calc(90vh - 56px - 100px); @@ -133,9 +133,11 @@ width: 100%; display: grid; text-align: center; - + // there's probably a better way to do this, but wanted to try out CSS grid - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( + 100% / 7 + ) calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--secondary-text-color-dark); @@ -149,14 +151,16 @@ width: 100%; display: grid; text-align: center; - grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7) calc(100% / 7); + grid-template-columns: calc(100% / 7) calc(100% / 7) calc(100% / 7) calc( + 100% / 7 + ) calc(100% / 7) calc(100% / 7) calc(100% / 7); span { color: var(--primary-text-color-dark); line-height: 42px; font-size: 14px; display: inline-grid; - transition: color .22s; + transition: color 0.22s; height: 42px; position: relative; cursor: pointer; @@ -164,7 +168,7 @@ border-radius: 50%; &::before { - content: ''; + content: ""; position: absolute; z-index: -1; height: 42px; @@ -172,12 +176,12 @@ left: calc(50% - 21px); background: var(--primary-color); border-radius: 50%; - transition: transform .22s, opacity .22s; + transition: transform 0.22s, opacity 0.22s; transform: scale(0); opacity: 0; } - - &[disabled=true] { + + &[disabled="true"] { cursor: unset; } @@ -186,7 +190,7 @@ } &.datePicker--selected { - color: rgba(255,255,255,.87); + color: rgba(255, 255, 255, 0.87); &:before { transform: scale(1); @@ -196,21 +200,21 @@ } } } - + .datePicker--selectYear { padding: 0 20px; display: block; width: 100%; text-align: center; max-height: 362px; - + span { display: block; width: 100%; font-size: 24px; margin: 20px auto; cursor: pointer; - + &.selected { font-size: 42px; color: var(--primary-color); @@ -236,9 +240,10 @@ appearance: none; padding: 0 16px; border-radius: 3px; - transition: background-color .13s; + transition: background-color 0.13s; - &:hover, &:focus { + &:hover, + &:focus { outline: none; background-color: var(--disabled-text-color-dark); } @@ -253,6 +258,6 @@ left: 0; bottom: 0; right: 0; - background: rgba(0,0,0,.52); - animation: fadeIn .22s forwards; + background: rgba(0, 0, 0, 0.52); + animation: fadeIn 0.22s forwards; } diff --git a/packages/anastasis-webui/src/scss/_footer.scss b/packages/anastasis-webui/src/scss/_footer.scss index 027a5ca8b..112522ed8 100644 --- a/packages/anastasis-webui/src/scss/_footer.scss +++ b/packages/anastasis-webui/src/scss/_footer.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_form.scss b/packages/anastasis-webui/src/scss/_form.scss index 71f0d4da4..786044eff 100644 --- a/packages/anastasis-webui/src/scss/_form.scss +++ b/packages/anastasis-webui/src/scss/_form.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -22,11 +22,12 @@ .field { &.has-check { .field-body { - margin-top: $default-padding * .125; + margin-top: $default-padding * 0.125; } } .control { - .mdi-24px.mdi-set, .mdi-24px.mdi:before { + .mdi-24px.mdi-set, + .mdi-24px.mdi:before { font-size: inherit; } } @@ -37,28 +38,34 @@ } } -.input, .textarea, select { +.input, +.textarea, +select { box-shadow: none; - &:focus, &:active { - box-shadow: none!important; + &:focus, + &:active { + box-shadow: none !important; } } -.switch input[type=checkbox]+.check:before { +.switch input[type="checkbox"] + .check:before { box-shadow: none; } -.switch, .b-checkbox.checkbox { - input[type=checkbox] { - &:focus + .check, &:focus:checked + .check { - box-shadow: none!important; +.switch, +.b-checkbox.checkbox { + input[type="checkbox"] { + &:focus + .check, + &:focus:checked + .check { + box-shadow: none !important; } } } -.b-checkbox.checkbox input[type=checkbox], .b-radio.radio input[type=radio] { - &+.check { +.b-checkbox.checkbox input[type="checkbox"], +.b-radio.radio input[type="radio"] { + & + .check { border: $checkbox-border; } } diff --git a/packages/anastasis-webui/src/scss/_hero-bar.scss b/packages/anastasis-webui/src/scss/_hero-bar.scss index 90b67a2ed..31b7e623e 100644 --- a/packages/anastasis-webui/src/scss/_hero-bar.scss +++ b/packages/anastasis-webui/src/scss/_hero-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -32,17 +32,17 @@ section.hero.is-hero-bar { } > div > .level { - margin-bottom: $default-padding * .5; + margin-bottom: $default-padding * 0.5; } .subtitle + p { - margin-top: $default-padding * .5; + margin-top: $default-padding * 0.5; } } .button { &.is-hero-button { - background-color: rgba($white, .5); + background-color: rgba($white, 0.5); font-weight: 300; @include transition(background-color); diff --git a/packages/anastasis-webui/src/scss/_main-section.scss b/packages/anastasis-webui/src/scss/_main-section.scss index 1a4fad81d..01edc24bf 100644 --- a/packages/anastasis-webui/src/scss/_main-section.scss +++ b/packages/anastasis-webui/src/scss/_main-section.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_mixins.scss b/packages/anastasis-webui/src/scss/_mixins.scss index 0809033ed..b52e590e3 100644 --- a/packages/anastasis-webui/src/scss/_mixins.scss +++ b/packages/anastasis-webui/src/scss/_mixins.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -23,12 +23,12 @@ transition: $t 250ms ease-in-out 50ms; } -@mixin icon-with-update-mark ($icon-base-width) { +@mixin icon-with-update-mark($icon-base-width) { .icon { width: $icon-base-width; &.has-update-mark:after { - right: ($icon-base-width / 2) - .85; + right: ($icon-base-width / 2) - 0.85; } } } diff --git a/packages/anastasis-webui/src/scss/_modal.scss b/packages/anastasis-webui/src/scss/_modal.scss index 3edbb8d3a..b3a31ebf1 100644 --- a/packages/anastasis-webui/src/scss/_modal.scss +++ b/packages/anastasis-webui/src/scss/_modal.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ diff --git a/packages/anastasis-webui/src/scss/_nav-bar.scss b/packages/anastasis-webui/src/scss/_nav-bar.scss index 09f1e2326..c6dd04263 100644 --- a/packages/anastasis-webui/src/scss/_nav-bar.scss +++ b/packages/anastasis-webui/src/scss/_nav-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -25,7 +25,7 @@ nav.navbar { .navbar-item { &.has-user-avatar { .is-user-avatar { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; display: inline-flex; width: $navbar-avatar-size; height: $navbar-avatar-size; @@ -98,11 +98,11 @@ nav.navbar { .navbar-item { .icon:first-child { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; } &.has-dropdown { - >.navbar-link { + > .navbar-link { background-color: $white-ter; .icon:last-child { display: none; @@ -111,11 +111,11 @@ nav.navbar { } &.has-user-avatar { - >.navbar-link { + > .navbar-link { display: flex; align-items: center; - padding-top: $default-padding * .5; - padding-bottom: $default-padding * .5; + padding-top: $default-padding * 0.5; + padding-bottom: $default-padding * 0.5; } } } @@ -131,7 +131,7 @@ nav.navbar { &:not(.is-desktop-icon-only) { .icon:first-child { - margin-right: $default-padding * .5; + margin-right: $default-padding * 0.5; } } &.is-desktop-icon-only { diff --git a/packages/anastasis-webui/src/scss/_table.scss b/packages/anastasis-webui/src/scss/_table.scss index 9cf6f4dcd..b68d50e4f 100644 --- a/packages/anastasis-webui/src/scss/_table.scss +++ b/packages/anastasis-webui/src/scss/_table.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -26,7 +26,8 @@ table.table { } } - td, th { + td, + th { &.checkbox-cell { .b-checkbox.checkbox:not(.button) { margin-right: 0; @@ -83,7 +84,9 @@ table.table { } } - .pagination-previous, .pagination-next, .pagination-link { + .pagination-previous, + .pagination-next, + .pagination-link { border-color: $button-border-color; color: $base-color; @@ -108,24 +111,25 @@ table.table { &.has-mobile-sort-spaced { .b-table { .field.table-mobile-sort { - padding-top: $default-padding * .5; + padding-top: $default-padding * 0.5; } } } } .b-table { .field.table-mobile-sort { - padding: 0 $default-padding * .5; + padding: 0 $default-padding * 0.5; } .table-wrapper.has-mobile-cards { tr { box-shadow: 0 2px 3px rgba(10, 10, 10, 0.1); - margin-bottom: 3px!important; + margin-bottom: 3px !important; } td { &.is-progress-col { - span, progress { + span, + progress { display: flex; width: 45%; align-items: center; @@ -133,11 +137,13 @@ table.table { } } - &.checkbox-cell, &.is-image-cell { - border-bottom: 0!important; + &.checkbox-cell, + &.is-image-cell { + border-bottom: 0 !important; } - &.checkbox-cell, &.is-actions-cell { + &.checkbox-cell, + &.is-actions-cell { &:before { display: none; } @@ -163,7 +169,7 @@ table.table { .image { width: $table-avatar-size-mobile; height: auto; - margin: 0 auto $default-padding * .25; + margin: 0 auto $default-padding * 0.25; } } } diff --git a/packages/anastasis-webui/src/scss/_tiles.scss b/packages/anastasis-webui/src/scss/_tiles.scss index 94fc04e70..e69d995f0 100644 --- a/packages/anastasis-webui/src/scss/_tiles.scss +++ b/packages/anastasis-webui/src/scss/_tiles.scss @@ -14,12 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ - .is-tiles-wrapper { margin-bottom: $default-padding; } diff --git a/packages/anastasis-webui/src/scss/_title-bar.scss b/packages/anastasis-webui/src/scss/_title-bar.scss index 736f26cbd..932f8e65d 100644 --- a/packages/anastasis-webui/src/scss/_title-bar.scss +++ b/packages/anastasis-webui/src/scss/_title-bar.scss @@ -14,7 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - /** +/** * * @author Sebastian Javier Marchano (sebasjm) */ @@ -26,14 +26,14 @@ section.section.is-title-bar { ul { li { display: inline-block; - padding: 0 $default-padding * .5 0 0; + padding: 0 $default-padding * 0.5 0 0; font-size: $default-padding; color: $title-bar-color; &:after { display: inline-block; - content: '/'; - padding-left: $default-padding * .5; + content: "/"; + padding-left: $default-padding * 0.5; } &:last-child { diff --git a/packages/anastasis-webui/src/scss/main.scss b/packages/anastasis-webui/src/scss/main.scss index b5335073f..9311fbba0 100644 --- a/packages/anastasis-webui/src/scss/main.scss +++ b/packages/anastasis-webui/src/scss/main.scss @@ -190,7 +190,6 @@ div[data-tooltip]::before { border: solid 1px #f2e9bf; } - .home { padding: 1em 1em; min-height: 100%; @@ -218,9 +217,9 @@ div[data-tooltip]::before { } .profile { - padding: 56px 20px; - min-height: 100%; - width: 100%; + padding: 56px 20px; + min-height: 100%; + width: 100%; } .notfound { @@ -232,4 +231,4 @@ h1 { font-size: 1.5em; margin-top: 0.8em; margin-bottom: 0.8em; -}
\ No newline at end of file +} diff --git a/packages/anastasis-webui/src/template.html b/packages/anastasis-webui/src/template.html index 351f1829c..8ae2fe104 100644 --- a/packages/anastasis-webui/src/template.html +++ b/packages/anastasis-webui/src/template.html @@ -1,15 +1,51 @@ +<!-- + 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 +--> <!DOCTYPE html> -<html lang="en" class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded"> - <head> - <meta charset="utf-8"> - <title><% preact.title %></title> - <meta name="viewport" content="width=device-width,initial-scale=1"> - <meta name="mobile-web-app-capable" content="yes"> - <meta name="apple-mobile-web-app-capable" content="yes"> - <link rel="apple-touch-icon" href="/assets/icons/apple-touch-icon.png"> - <% preact.headEnd %> - </head> - <body> - <% preact.bodyEnd %> - </body> +<html + lang="en" + class="has-aside-left has-aside-mobile-transition has-navbar-fixed-top has-aside-expanded" +> + <head> + <meta charset="utf-8" /> + <title><%= htmlWebpackPlugin.options.title %></title> + <meta name="viewport" content="width=device-width,initial-scale=1" /> + <meta name="mobile-web-app-capable" content="yes" /> + <meta name="apple-mobile-web-app-capable" content="yes" /> + + <link + rel="icon" + href="data:;base64,AAABAAEAEBAAAAEAIABoBAAAFgAAACgAAAAQAAAAIAAAAAEAIAAAAAAAAAQAABILAAASCwAAAAAAAAAAAAD///////////////////////////////////////////////////////////////////////////////////////////////////7//v38//78/P/+/fz//vz7///+/v/+/f3//vz7///+/v/+/fz//v38///////////////////////+/v3///7+/////////////////////////////////////////////////////////v3//v79///////+/v3///////r28v/ct5//06SG/9Gffv/Xqo7/7N/V/9e2nf/bsJb/6uDW/9Sskf/euKH/+/j2///////+/v3//////+3azv+/eE3/2rWd/9Kkhv/Vr5T/48i2/8J+VP/Qn3//3ryn/795Tf/WrpP/2LCW/8B6T//w4Nb///////Pn4P+/d0v/9u3n/+7d0v/EhV7//v///+HDr//fxLD/zph2/+TJt//8/Pv/woBX//Lm3f/y5dz/v3hN//bu6f/JjGn/4sW0///////Df1j/8OLZ//v6+P+/elH/+vj1//jy7f+/elL//////+zYzP/Eg13//////967p//MlHT/wn5X///////v4Nb/yY1s///////jw7H/06KG////////////z5t9/+fNvf//////x4pn//Pp4v/8+vn/w39X/8WEX///////5s/A/9CbfP//////27Oc/9y2n////////////9itlf/gu6f//////86Vdf/r2Mz//////8SCXP/Df1j//////+7d0v/KkG7//////+HBrf/VpYr////////////RnoH/5sq6///////Ii2n/8ubf//39/P/Cf1j/xohk/+bNvv//////wn5W//Tq4//58/D/wHxV//7+/f/59fH/v3xU//39/P/w4Nf/xIFb///////hw7H/yo9t/+/f1f/AeU3/+/n2/+nSxP/FhmD//////9qzm//Upon/4MSx/96+qf//////xINc/+3bz//48e3/v3hN//Pn3///////6M+//752S//gw6//06aK/8J+VP/kzLr/zZd1/8OCWv/q18r/17KZ/9Ooi//fv6r/v3dK/+vWyP///////v39///////27un/1aeK/9Opjv/m1cf/1KCC/9a0nP/n08T/0Jx8/82YdP/QnHz/16yR//jx7P///////v39///////+/f3///7+///////+//7//v7+///////+/v7//v/+/////////////////////////v7//v79///////////////////+/v/+/Pv//v39///+/v/+/Pv///7+//7+/f/+/Pv//v39//79/P/+/Pv///7+////////////AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" + /> + <link rel="shortcut icon" href="data:image/x-icon;," type="image/x-icon" /> + + <% if (htmlWebpackPlugin.options.manifest.theme_color) { %> + <meta + name="theme-color" + content="<%= htmlWebpackPlugin.options.manifest.theme_color %>" + /> + <% } %> + </head> + <body> + <script> + <%= compilation.assets[htmlWebpackPlugin.files.chunks["polyfills"].entry.substr(htmlWebpackPlugin.files.publicPath.length)].source() %> + </script> + <script> + <%= compilation.assets[htmlWebpackPlugin.files.chunks["bundle"].entry.substr(htmlWebpackPlugin.files.publicPath.length)].source() %> + </script> + </body> </html> diff --git a/packages/anastasis-webui/src/utils/index.tsx b/packages/anastasis-webui/src/utils/index.tsx index 9c01aa6ba..a8f6c3101 100644 --- a/packages/anastasis-webui/src/utils/index.tsx +++ b/packages/anastasis-webui/src/utils/index.tsx @@ -1,45 +1,67 @@ /* eslint-disable @typescript-eslint/camelcase */ -import { BackupStates, RecoveryStates, ReducerState } from 'anastasis-core'; -import { FunctionalComponent, h, VNode } from 'preact'; -import { AnastasisProvider } from '../context/anastasis'; +import { BackupStates, RecoveryStates, ReducerState } from "anastasis-core"; +import { FunctionalComponent, h, VNode } from "preact"; +import { AnastasisProvider } from "../context/anastasis"; -export function createExample<Props>(Component: FunctionalComponent<Props>, currentReducerState?: ReducerState, props?: Partial<Props>): { (args: Props): VNode } { +export function createExample<Props>( + Component: FunctionalComponent<Props>, + currentReducerState?: ReducerState, + props?: Partial<Props>, +): { (args: Props): VNode } { const r = (args: Props): VNode => { - return <AnastasisProvider value={{ - currentReducerState, - currentError: undefined, - back: async () => { null }, - dismissError: async () => { null }, - reset: () => { null }, - runTransaction: async () => { null }, - startBackup: () => { null }, - startRecover: () => { null }, - transition: async () => { null }, - }}> - <Component {...args} /> - </AnastasisProvider> - } - r.args = props - return r + return ( + <AnastasisProvider + value={{ + currentReducerState, + currentError: undefined, + back: async () => { + null; + }, + dismissError: async () => { + null; + }, + reset: () => { + null; + }, + runTransaction: async () => { + null; + }, + startBackup: () => { + null; + }, + startRecover: () => { + null; + }, + transition: async () => { + null; + }, + }} + > + <Component {...args} /> + </AnastasisProvider> + ); + }; + r.args = props; + return r; } const base = { continents: [ { - name: "Europe" + name: "Europe", }, { - name: "India" + name: "India", }, { - name: "Asia" + name: "Asia", }, { - name: "North America" + name: "North America", }, { - name: "Testcontinent" - } + name: "Testcontinent", + }, ], countries: [ { @@ -47,122 +69,124 @@ const base = { name: "Testland", continent: "Testcontinent", continent_i18n: { - de_DE: "Testkontinent" + de_DE: "Testkontinent", }, name_i18n: { de_DE: "Testlandt", de_CH: "Testlandi", fr_FR: "Testpais", - en_UK: "Testland" + en_UK: "Testland", }, currency: "TESTKUDOS", - call_code: "+00" + call_code: "+00", }, { code: "xy", name: "Demoland", continent: "Testcontinent", continent_i18n: { - de_DE: "Testkontinent" + de_DE: "Testkontinent", }, name_i18n: { de_DE: "Demolandt", de_CH: "Demolandi", fr_FR: "Demopais", - en_UK: "Demoland" + en_UK: "Demoland", }, currency: "KUDOS", - call_code: "+01" - } + call_code: "+01", + }, ], authentication_providers: { "http://localhost:8086/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Local", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "sms", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "email", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "https://kudos.demo.anastasis.lu/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Kudo", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "email", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "https://anastasis.demo.taler.net/": { http_status: 200, annual_fee: "COL:0", - business_name: "ana", + business_name: "Anastasis Demo", currency: "COL", liability_limit: "COL:10", methods: [ { type: "question", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "sms", - usage_fee: "COL:0" - }, { + usage_fee: "COL:0", + }, + { type: "totp", - usage_fee: "COL:0" + usage_fee: "COL:0", }, ], salt: "WBMDD76BR1E90YQ5AHBMKPH7GW", storage_limit_in_megabytes: 16, - truth_upload_fee: "COL:0" + truth_upload_fee: "COL:0", }, "http://localhost:8087/": { code: 8414, - hint: "request to provider failed" + hint: "request to provider failed", }, "http://localhost:8088/": { code: 8414, - hint: "request to provider failed" + hint: "request to provider failed", }, "http://localhost:8089/": { code: 8414, - hint: "request to provider failed" - } + hint: "request to provider failed", + }, }, - // expiration: { - // d_ms: 1792525051855 // check t_ms - // }, -} as Partial<ReducerState> +} as Partial<ReducerState>; export const reducerStatesExample = { initial: undefined, recoverySelectCountry: { ...base, - recovery_state: RecoveryStates.CountrySelecting + recovery_state: RecoveryStates.CountrySelecting, } as ReducerState, recoverySelectContinent: { ...base, @@ -190,11 +214,11 @@ export const reducerStatesExample = { } as ReducerState, recoveryAttributeEditing: { ...base, - recovery_state: RecoveryStates.UserAttributesCollecting + recovery_state: RecoveryStates.UserAttributesCollecting, } as ReducerState, backupSelectCountry: { ...base, - backup_state: BackupStates.CountrySelecting + backup_state: BackupStates.CountrySelecting, } as ReducerState, backupSelectContinent: { ...base, @@ -218,15 +242,14 @@ export const reducerStatesExample = { } as ReducerState, authEditing: { ...base, - backup_state: BackupStates.AuthenticationsEditing + backup_state: BackupStates.AuthenticationsEditing, } as ReducerState, backupAttributeEditing: { ...base, - backup_state: BackupStates.UserAttributesCollecting + backup_state: BackupStates.UserAttributesCollecting, } as ReducerState, truthsPaying: { ...base, - backup_state: BackupStates.TruthsPaying + backup_state: BackupStates.TruthsPaying, } as ReducerState, - -} +}; diff --git a/packages/taler-util/src/backupTypes.ts b/packages/taler-util/src/backupTypes.ts index 70e52e63b..ecdd6fdf8 100644 --- a/packages/taler-util/src/backupTypes.ts +++ b/packages/taler-util/src/backupTypes.ts @@ -53,6 +53,7 @@ /** * Imports. */ +import { DenominationPubKey, UnblindedSignature } from "./talerTypes.js"; import { Duration, Timestamp } from "./time.js"; /** @@ -440,7 +441,7 @@ export interface BackupCoin { /** * Unblinded signature by the exchange. */ - denom_sig: string; + denom_sig: UnblindedSignature; /** * Amount that's left on the coin. @@ -831,7 +832,7 @@ export interface BackupDenomination { /** * The denomination public key. */ - denom_pub: string; + denom_pub: DenominationPubKey; /** * Fee for withdrawing. diff --git a/packages/taler-util/src/helpers.ts b/packages/taler-util/src/helpers.ts index 089602c9d..6c836c482 100644 --- a/packages/taler-util/src/helpers.ts +++ b/packages/taler-util/src/helpers.ts @@ -94,7 +94,7 @@ export function canonicalJson(obj: any): string { /** * Lexically compare two strings. */ -export function strcmp(s1: string, s2: string): number { +export function strcmp(s1: string, s2: string): -1 | 0 | 1 { if (s1 < s2) { return -1; } @@ -113,15 +113,14 @@ export function j2s(x: any): string { /** * Use this to filter null or undefined from an array in a type-safe fashion - * + * * example: * const array: Array<T | undefined> = [undefined, null] * const filtered: Array<T> = array.filter(notEmpty) - * - * @param value - * @returns + * + * @param value + * @returns */ export function notEmpty<T>(value: T | null | undefined): value is T { return value !== null && value !== undefined; } - diff --git a/packages/taler-util/src/payto.ts b/packages/taler-util/src/payto.ts index 504db533b..fc3380555 100644 --- a/packages/taler-util/src/payto.ts +++ b/packages/taler-util/src/payto.ts @@ -16,12 +16,31 @@ import { URLSearchParams } from "./url.js"; -interface PaytoUri { +export type PaytoUri = PaytoUriUnknown | PaytoUriIBAN | PaytoUriTalerBank; + +interface PaytoUriGeneric { targetType: string; targetPath: string; params: { [name: string]: string }; } +interface PaytoUriUnknown extends PaytoUriGeneric { + isKnown: false; +} + +interface PaytoUriIBAN extends PaytoUriGeneric { + isKnown: true; + targetType: 'iban', + iban: string; +} + +interface PaytoUriTalerBank extends PaytoUriGeneric { + isKnown: true; + targetType: 'x-taler-bank', + host: string; + account: string; +} + const paytoPfx = "payto://"; /** @@ -63,9 +82,33 @@ export function parsePaytoUri(s: string): PaytoUri | undefined { params[v] = k; }); + if (targetType === 'x-taler-bank') { + const parts = targetPath.split('/') + const host = parts[0] + const account = parts[1] + return { + targetPath, + targetType, + params, + isKnown: true, + host, account, + }; + + } + if (targetType === 'iban') { + return { + isKnown: true, + targetPath, + targetType, + params, + iban: targetPath + }; + + } return { targetPath, targetType, params, + isKnown: false }; } diff --git a/packages/taler-util/src/talerCrypto.ts b/packages/taler-util/src/talerCrypto.ts index 536c4dc48..b107786cd 100644 --- a/packages/taler-util/src/talerCrypto.ts +++ b/packages/taler-util/src/talerCrypto.ts @@ -24,6 +24,7 @@ import * as nacl from "./nacl-fast.js"; import { kdf } from "./kdf.js"; import bigint from "big-integer"; +import { DenominationPubKey } from "./talerTypes.js"; export function getRandomBytes(n: number): Uint8Array { return nacl.randomBytes(n); @@ -161,10 +162,6 @@ interface RsaPub { e: bigint.BigInteger; } -interface RsaBlindingKey { - r: bigint.BigInteger; -} - /** * KDF modulo a big integer. */ @@ -352,6 +349,20 @@ export function hash(d: Uint8Array): Uint8Array { return nacl.hash(d); } +export function hashDenomPub(pub: DenominationPubKey): Uint8Array { + if (pub.cipher !== 1) { + throw Error("unsupported cipher"); + } + const pubBuf = decodeCrock(pub.rsa_public_key); + const hashInputBuf = new ArrayBuffer(pubBuf.length + 4 + 4); + const uint8ArrayBuf = new Uint8Array(hashInputBuf); + const dv = new DataView(hashInputBuf); + dv.setUint32(0, pub.age_mask ?? 0); + dv.setUint32(4, pub.cipher); + uint8ArrayBuf.set(pubBuf, 8); + return nacl.hash(uint8ArrayBuf); +} + export function eddsaSign(msg: Uint8Array, eddsaPriv: Uint8Array): Uint8Array { const pair = nacl.crypto_sign_keyPair_fromSeed(eddsaPriv); return nacl.sign_detached(msg, pair.secretKey); diff --git a/packages/taler-util/src/talerTypes.ts b/packages/taler-util/src/talerTypes.ts index 56110ec1e..04d700483 100644 --- a/packages/taler-util/src/talerTypes.ts +++ b/packages/taler-util/src/talerTypes.ts @@ -59,7 +59,7 @@ export class Denomination { /** * Public signing key of the denomination. */ - denom_pub: string; + denom_pub: DenominationPubKey; /** * Fee for withdrawing. @@ -158,7 +158,7 @@ export interface RecoupRequest { /** * Signature over the coin public key by the denomination. */ - denom_sig: string; + denom_sig: UnblindedSignature; /** * Coin public key of the coin we want to refund. @@ -198,6 +198,11 @@ export interface RecoupConfirmation { old_coin_pub?: string; } +export interface UnblindedSignature { + cipher: DenomKeyType.Rsa; + rsa_signature: string; +} + /** * Deposit permission for a single coin. */ @@ -213,7 +218,7 @@ export interface CoinDepositPermission { /** * Signature made by the denomination public key. */ - ub_sig: string; + ub_sig: UnblindedSignature; /** * The denomination public key associated with this coin. */ @@ -779,8 +784,38 @@ export class TipPickupGetResponse { expiration: Timestamp; } +export enum DenomKeyType { + Rsa = 1, + ClauseSchnorr = 2, +} + +export interface RsaBlindedDenominationSignature { + cipher: DenomKeyType.Rsa; + blinded_rsa_signature: string; +} + +export interface CSBlindedDenominationSignature { + cipher: DenomKeyType.ClauseSchnorr; +} + +export type BlindedDenominationSignature = + | RsaBlindedDenominationSignature + | CSBlindedDenominationSignature; + +export const codecForBlindedDenominationSignature = () => + buildCodecForUnion<BlindedDenominationSignature>() + .discriminateOn("cipher") + .alternative(1, codecForRsaBlindedDenominationSignature()) + .build("BlindedDenominationSignature"); + +export const codecForRsaBlindedDenominationSignature = () => + buildCodecForObject<RsaBlindedDenominationSignature>() + .property("cipher", codecForConstNumber(1)) + .property("blinded_rsa_signature", codecForString()) + .build("RsaBlindedDenominationSignature"); + export class WithdrawResponse { - ev_sig: string; + ev_sig: BlindedDenominationSignature; } /** @@ -792,7 +827,7 @@ export interface CoinDumpJson { /** * The coin's denomination's public key. */ - denom_pub: string; + denom_pub: DenominationPubKey; /** * Hash of denom_pub. */ @@ -875,7 +910,7 @@ export interface ExchangeMeltResponse { } export interface ExchangeRevealItem { - ev_sig: string; + ev_sig: BlindedDenominationSignature; } export interface ExchangeRevealResponse { @@ -994,6 +1029,30 @@ export interface BankWithdrawalOperationPostResponse { transfer_done: boolean; } +export type DenominationPubKey = RsaDenominationPubKey | CsDenominationPubKey; + +export interface RsaDenominationPubKey { + cipher: 1; + rsa_public_key: string; + age_mask?: number; +} + +export interface CsDenominationPubKey { + cipher: 2; +} + +export const codecForDenominationPubKey = () => + buildCodecForUnion<DenominationPubKey>() + .discriminateOn("cipher") + .alternative(1, codecForRsaDenominationPubKey()) + .build("DenominationPubKey"); + +export const codecForRsaDenominationPubKey = () => + buildCodecForObject<RsaDenominationPubKey>() + .property("cipher", codecForConstNumber(1)) + .property("rsa_public_key", codecForString()) + .build("DenominationPubKey"); + export const codecForBankWithdrawalOperationPostResponse = (): Codec<BankWithdrawalOperationPostResponse> => buildCodecForObject<BankWithdrawalOperationPostResponse>() .property("transfer_done", codecForBoolean()) @@ -1008,7 +1067,7 @@ export type CoinPublicKeyString = string; export const codecForDenomination = (): Codec<Denomination> => buildCodecForObject<Denomination>() .property("value", codecForString()) - .property("denom_pub", codecForString()) + .property("denom_pub", codecForDenominationPubKey()) .property("fee_withdraw", codecForString()) .property("fee_deposit", codecForString()) .property("fee_refresh", codecForString()) @@ -1242,7 +1301,7 @@ export const codecForRecoupConfirmation = (): Codec<RecoupConfirmation> => export const codecForWithdrawResponse = (): Codec<WithdrawResponse> => buildCodecForObject<WithdrawResponse>() - .property("ev_sig", codecForString()) + .property("ev_sig", codecForBlindedDenominationSignature()) .build("WithdrawResponse"); export const codecForMerchantPayResponse = (): Codec<MerchantPayResponse> => @@ -1260,7 +1319,7 @@ export const codecForExchangeMeltResponse = (): Codec<ExchangeMeltResponse> => export const codecForExchangeRevealItem = (): Codec<ExchangeRevealItem> => buildCodecForObject<ExchangeRevealItem>() - .property("ev_sig", codecForString()) + .property("ev_sig", codecForBlindedDenominationSignature()) .build("ExchangeRevealItem"); export const codecForExchangeRevealResponse = (): Codec<ExchangeRevealResponse> => diff --git a/packages/taler-util/src/taleruri.ts b/packages/taler-util/src/taleruri.ts index 09c70682a..b487c73ae 100644 --- a/packages/taler-util/src/taleruri.ts +++ b/packages/taler-util/src/taleruri.ts @@ -57,6 +57,13 @@ export function parseWithdrawUri(s: string): WithdrawUriResult | undefined { const host = parts[0].toLowerCase(); const pathSegments = parts.slice(1, parts.length - 1); + /** + * The statement below does not tolerate a slash-ended URI. + * This results in (1) the withdrawalId being passed as the + * empty string, and (2) the bankIntegrationApi ending with the + * actual withdrawal operation ID. That can be fixed by + * trimming the parts-list. FIXME + */ const withdrawId = parts[parts.length - 1]; const p = [host, ...pathSegments].join("/"); diff --git a/packages/taler-util/src/walletTypes.ts b/packages/taler-util/src/walletTypes.ts index 6e68ee080..879640e82 100644 --- a/packages/taler-util/src/walletTypes.ts +++ b/packages/taler-util/src/walletTypes.ts @@ -48,6 +48,8 @@ import { AmountString, codecForContractTerms, ContractTerms, + DenominationPubKey, + UnblindedSignature, } from "./talerTypes.js"; import { OrderShortInfo, codecForOrderShortInfo } from "./transactionsTypes.js"; import { BackupRecovery } from "./backupTypes.js"; @@ -454,7 +456,7 @@ export interface PlanchetCreationResult { coinPriv: string; reservePub: string; denomPubHash: string; - denomPub: string; + denomPub: DenominationPubKey; blindingKey: string; withdrawSig: string; coinEv: string; @@ -467,7 +469,7 @@ export interface PlanchetCreationRequest { coinIndex: number; value: AmountJson; feeWithdraw: AmountJson; - denomPub: string; + denomPub: DenominationPubKey; reservePub: string; reservePriv: string; } @@ -514,7 +516,7 @@ export interface DepositInfo { feeDeposit: AmountJson; wireInfoHash: string; denomPubHash: string; - denomSig: string; + denomSig: UnblindedSignature; } export interface ExchangesListRespose { diff --git a/packages/taler-wallet-cli/src/harness/harness.ts b/packages/taler-wallet-cli/src/harness/harness.ts index f8dd15738..4944e3471 100644 --- a/packages/taler-wallet-cli/src/harness/harness.ts +++ b/packages/taler-wallet-cli/src/harness/harness.ts @@ -65,6 +65,8 @@ import { EddsaKeyPair, encodeCrock, getRandomBytes, + hash, + stringToBytes } from "@gnu-taler/taler-util"; import { CoinConfig } from "./denomStructures.js"; import { LibeufinNexusApi, LibeufinSandboxApi } from "./libeufin-apis.js"; @@ -431,8 +433,7 @@ function setCoin(config: Configuration, c: CoinConfig) { } /** - * Send an HTTP request until it succeeds or the - * process dies. + * Send an HTTP request until it suceeds or the process dies. */ export async function pingProc( proc: ProcessWrapper | undefined, @@ -523,22 +524,26 @@ export namespace BankApi { password: string, ): Promise<BankUser> { const url = new URL("testing/register", bank.baseUrl); - await axios.post(url.href, { + let resp = await axios.post(url.href, { username, password, }); + let paytoUri = `payto://x-taler-bank/localhost/${username}`; + if (process.env.WALLET_HARNESS_WITH_EUFIN) { + paytoUri = resp.data.paytoUri; + } return { password, username, - accountPaytoUri: `payto://x-taler-bank/localhost/${username}`, + accountPaytoUri: paytoUri, }; } export async function createRandomBankUser( bank: BankServiceInterface, ): Promise<BankUser> { - const username = "user-" + encodeCrock(getRandomBytes(10)); - const password = "pw-" + encodeCrock(getRandomBytes(10)); + const username = "user-" + encodeCrock(getRandomBytes(10)).toLowerCase(); + const password = "pw-" + encodeCrock(getRandomBytes(10)).toLowerCase(); return await registerAccount(bank, username, password); } @@ -551,9 +556,14 @@ export namespace BankApi { debitAccountPayto: string; }, ) { - const url = new URL( + + let maybeBaseUrl = bank.baseUrl; + if (process.env.WALLET_HARNESS_WITH_EUFIN) { + maybeBaseUrl = (bank as EufinBankService).baseUrlDemobank; + } + let url = new URL( `taler-wire-gateway/${params.exchangeBankAccount.accountName}/admin/add-incoming`, - bank.baseUrl, + maybeBaseUrl, ); await axios.post( url.href, @@ -623,28 +633,55 @@ class BankServiceBase { * Work in progress. The key point is that both Sandbox and Nexus * will be configured and started by this class. */ -class LibeufinBankService extends BankServiceBase implements BankService { +class EufinBankService extends BankServiceBase implements BankServiceInterface { sandboxProc: ProcessWrapper | undefined; nexusProc: ProcessWrapper | undefined; static async create( gc: GlobalTestState, bc: BankConfig, - ): Promise<BankService> { + ): Promise<EufinBankService> { - return new LibeufinBankService(gc, bc, "foo"); + return new EufinBankService(gc, bc, "foo"); } get port() { return this.bankConfig.httpPort; } + get nexusPort() { + return this.bankConfig.httpPort + 1000; + + } + + get nexusDbConn(): string { + return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-nexus.sqlite3`; + } + + get sandboxDbConn(): string { + return `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-sandbox.sqlite3`; + + } get nexusBaseUrl(): string { - return `http://localhost:${this.bankConfig.httpPort + 1}`; + return `http://localhost:${this.nexusPort}`; + } + + get baseUrlDemobank(): string { + let url = new URL("demobanks/default/", this.baseUrlNetloc); + return url.href; + } + + get baseUrlAccessApi(): string { + let url = new URL("access-api/", this.baseUrlDemobank); + return url.href; + } + + get baseUrlNetloc(): string { + return `http://localhost:${this.bankConfig.httpPort}/`; } get baseUrl(): string { - return `http://localhost:${this.bankConfig.httpPort}/demobanks/default/access-api`; + return this.baseUrlAccessApi; } async setSuggestedExchange( @@ -654,7 +691,11 @@ class LibeufinBankService extends BankServiceBase implements BankService { await sh( this.globalTestState, "libeufin-sandbox-set-default-exchange", - `libeufin-sandbox default-exchange ${exchangePayto}` + `libeufin-sandbox default-exchange ${e.baseUrl} ${exchangePayto}`, + { + ...process.env, + LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, + }, ); } @@ -663,38 +704,45 @@ class LibeufinBankService extends BankServiceBase implements BankService { accountName: string, password: string, ): Promise<HarnessExchangeBankAccount> { - + console.log("Create Exchange account(s)!"); + /** + * Many test cases try to create a Exchange account before + * starting the bank; that's because the Pybank did it entirely + * via the configuration file. + */ + await this.start(); + await this.pingUntilAvailable(); await LibeufinSandboxApi.createDemobankAccount( accountName, password, - { baseUrl: this.baseUrl } + { baseUrl: this.baseUrlAccessApi } ); - let bankAccountLabel = `${accountName}-acct` + let bankAccountLabel = accountName; await LibeufinSandboxApi.createDemobankEbicsSubscriber( { - hostID: "talertest-ebics-host", - userID: "exchange-ebics-user", - partnerID: "exchange-ebics-partner", + hostID: "talertestEbicsHost", + userID: "exchangeEbicsUser", + partnerID: "exchangeEbicsPartner", }, bankAccountLabel, - { baseUrl: this.baseUrl } + { baseUrl: this.baseUrlDemobank } ); await LibeufinNexusApi.createUser( { baseUrl: this.nexusBaseUrl }, { - username: `${accountName}-nexus-username`, - password: `${password}-nexus-password` + username: accountName, + password: password } ); await LibeufinNexusApi.createEbicsBankConnection( { baseUrl: this.nexusBaseUrl }, { name: "ebics-connection", // connection name. - ebicsURL: `http://localhost:${this.bankConfig.httpPort}/ebicsweb`, - hostID: "talertest-ebics-host", - userID: "exchange-ebics-user", - partnerID: "exchange-ebics-partner", + ebicsURL: (new URL("ebicsweb", this.baseUrlNetloc)).href, + hostID: "talertestEbicsHost", + userID: "exchangeEbicsUser", + partnerID: "exchangeEbicsPartner", } ); await LibeufinNexusApi.connectBankConnection( @@ -706,7 +754,7 @@ class LibeufinBankService extends BankServiceBase implements BankService { await LibeufinNexusApi.importConnectionAccount( { baseUrl: this.nexusBaseUrl }, "ebics-connection", // connection name - `${accountName}-acct`, // offered account label + accountName, // offered account label `${accountName}-nexus-label` // bank account label at Nexus ); await LibeufinNexusApi.createTwgFacade( @@ -724,7 +772,7 @@ class LibeufinBankService extends BankServiceBase implements BankService { { action: "grant", permission: { - subjectId: `${accountName}-nexus-username`, + subjectId: accountName, subjectType: "user", resourceType: "facade", resourceId: "exchange-facade", // facade name @@ -737,7 +785,7 @@ class LibeufinBankService extends BankServiceBase implements BankService { { action: "grant", permission: { - subjectId: `${accountName}-nexus-username`, + subjectId: accountName, subjectType: "user", resourceType: "facade", resourceId: "exchange-facade", // facade name @@ -745,12 +793,35 @@ class LibeufinBankService extends BankServiceBase implements BankService { }, } ); + // Set fetch task. + await LibeufinNexusApi.postTask( + { baseUrl: this.nexusBaseUrl }, + `${accountName}-nexus-label`, + { + name: "wirewatch-task", + cronspec: "* * *", + type: "fetch", + params: { + level: "all", + rangeType: "all", + }, + }); + await LibeufinNexusApi.postTask( + { baseUrl: this.nexusBaseUrl }, + `${accountName}-nexus-label`, + { + name: "aggregator-task", + cronspec: "* * *", + type: "submit", + params: {}, + } + ); let facadesResp = await LibeufinNexusApi.getAllFacades({ baseUrl: this.nexusBaseUrl }); let accountInfoResp = await LibeufinSandboxApi.demobankAccountInfo( - accountName, // username - password, - { baseUrl: this.nexusBaseUrl }, - `${accountName}acct` // bank account label. + "admin", + "secret", + { baseUrl: this.baseUrlAccessApi }, + accountName // bank account label. ); return { accountName: accountName, @@ -761,15 +832,36 @@ class LibeufinBankService extends BankServiceBase implements BankService { } async start(): Promise<void> { - let sandboxDb = `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-sandbox.sqlite3`; - let nexusDb = `jdbc:sqlite:${this.globalTestState.testDir}/libeufin-nexus.sqlite3`; + /** + * Because many test cases try to create a Exchange bank + * account _before_ starting the bank (Pybank did it only via + * the config), it is possible that at this point Sandbox and + * Nexus are already running. Hence, this method only launches + * them if they weren't launched earlier. + */ + + // Only go ahead if BOTH aren't running. + if (this.sandboxProc || this.nexusProc) { + console.log("Nexus or Sandbox already running, not taking any action."); + return; + } + await sh( + this.globalTestState, + "libeufin-sandbox-config-demobank", + `libeufin-sandbox config --currency=${this.bankConfig.currency} default`, + { + ...process.env, + LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, + LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret", + }, + ); this.sandboxProc = this.globalTestState.spawnService( "libeufin-sandbox", ["serve", "--port", `${this.port}`], "libeufin-sandbox", { ...process.env, - LIBEUFIN_SANDBOX_DB_CONNECTION: sandboxDb, + LIBEUFIN_SANDBOX_DB_CONNECTION: this.sandboxDbConn, LIBEUFIN_SANDBOX_ADMIN_PASSWORD: "secret", }, ); @@ -780,34 +872,48 @@ class LibeufinBankService extends BankServiceBase implements BankService { ["superuser", "admin", "--password", "test"], { ...process.env, - LIBEUFIN_NEXUS_DB_CONNECTION: nexusDb, + LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn, }, ); - this.nexusProc = this.globalTestState.spawnService( "libeufin-nexus", - ["serve", "--port", `${this.port + 1}`], + ["serve", "--port", `${this.nexusPort}`], "libeufin-nexus", { ...process.env, - LIBEUFIN_NEXUS_DB_CONNECTION: nexusDb, + LIBEUFIN_NEXUS_DB_CONNECTION: this.nexusDbConn, }, ); + // need to wait here, because at this point + // a Ebics host needs to be created (RESTfully) + await this.pingUntilAvailable(); + LibeufinSandboxApi.createEbicsHost( + { baseUrl: this.baseUrlNetloc }, + "talertestEbicsHost" + ); } async pingUntilAvailable(): Promise<void> { - await pingProc(this.sandboxProc, this.baseUrl, "libeufin-sandbox"); - await pingProc(this.nexusProc, `${this.baseUrl}config`, "libeufin-nexus"); + await pingProc( + this.sandboxProc, + `http://localhost:${this.bankConfig.httpPort}`, + "libeufin-sandbox" + ); + await pingProc( + this.nexusProc, + `${this.nexusBaseUrl}/config`, + "libeufin-nexus" + ); } } -export class BankService extends BankServiceBase implements BankServiceInterface { +class PybankService extends BankServiceBase implements BankServiceInterface { proc: ProcessWrapper | undefined; static async create( gc: GlobalTestState, bc: BankConfig, - ): Promise<BankService> { + ): Promise<PybankService> { const config = new Configuration(); setTalerPaths(config, gc.testDir + "/talerhome"); config.setString("taler", "currency", bc.currency); @@ -835,7 +941,7 @@ export class BankService extends BankServiceBase implements BankServiceInterface `taler-bank-manage -c '${cfgFilename}' django provide_accounts`, ); - return new BankService(gc, bc, cfgFilename); + return new PybankService(gc, bc, cfgFilename); } setSuggestedExchange(e: ExchangeServiceInterface, exchangePayto: string) { @@ -870,7 +976,7 @@ export class BankService extends BankServiceBase implements BankServiceInterface return { accountName: accountName, accountPassword: password, - accountPaytoUri: `payto://x-taler-bank/${accountName}`, + accountPaytoUri: getPayto(accountName), wireGatewayApiBaseUrl: `http://localhost:${this.bankConfig.httpPort}/taler-wire-gateway/${accountName}/`, }; } @@ -893,11 +999,31 @@ export class BankService extends BankServiceBase implements BankServiceInterface } } -// Still work in progress.. -if (false && process.env.WALLET_HARNESS_WITH_EUFIN) { - BankService.create = LibeufinBankService.create; - BankService.prototype = Object.create(LibeufinBankService.prototype); -} + +/** + * Return a euFin or a pyBank implementation of + * the exported BankService class. This allows + * to "dynamically export" such class depending + * on a particular env variable. + */ +function getBankServiceImpl(): { + prototype: typeof PybankService.prototype, + create: typeof PybankService.create +} { + + if (process.env.WALLET_HARNESS_WITH_EUFIN) + return { + prototype: EufinBankService.prototype, + create: EufinBankService.create + } + return { + prototype: PybankService.prototype, + create: PybankService.create + } +} + +export type BankService = PybankService; +export const BankService = getBankServiceImpl(); export class FakeBankService { proc: ProcessWrapper | undefined; @@ -1038,6 +1164,10 @@ export class ExchangeService implements ExchangeServiceInterface { } async runWirewatchOnce() { + if (process.env.WALLET_HARNESS_WITH_EUFIN) { + // Not even 2 secods showed to be enough! + await waitMs(4000); + } await runCommand( this.globalState, `exchange-${this.name}-wirewatch-once`, @@ -1699,7 +1829,7 @@ export class MerchantService implements MerchantServiceInterface { return await this.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, @@ -1956,3 +2086,46 @@ export class WalletCli { ); } } + +export function getRandomIban(salt: string | null = null): string { + + function getBban(salt: string | null): string { + if (!salt) + return Math.random().toString().substring(2, 6); + let hashed = hash(stringToBytes(salt)); + let ret = ""; + for (let i = 0; i < hashed.length; i++) { + ret += hashed[i].toString(); + } + return ret.substring(0, 4); + } + + let cc_no_check = "131400"; // == DE00 + let bban = getBban(salt) + let check_digits = (98 - (Number.parseInt(`${bban}${cc_no_check}`) % 97)).toString(); + if (check_digits.length == 1) { + check_digits = `0${check_digits}`; + } + return `DE${check_digits}${bban}`; +} + +// Only used in one tipping test. +export function getWireMethod(): string { + if (process.env.WALLET_HARNESS_WITH_EUFIN) + return "iban" + return "x-taler-bank" +} + +/** + * Generate a payto address, whose authority depends + * on whether the banking is served by euFin or Pybank. + */ +export function getPayto(label: string): string { + if (process.env.WALLET_HARNESS_WITH_EUFIN) + return `payto://iban/SANDBOXX/${getRandomIban(label)}?receiver-name=${label}` + return `payto://x-taler-bank/${label}` +} + +function waitMs(ms: number): Promise<void> { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/packages/taler-wallet-cli/src/harness/helpers.ts b/packages/taler-wallet-cli/src/harness/helpers.ts index 6ff62504b..bac2eefc1 100644 --- a/packages/taler-wallet-cli/src/harness/helpers.ts +++ b/packages/taler-wallet-cli/src/harness/helpers.ts @@ -50,6 +50,7 @@ import { MerchantPrivateApi, HarnessExchangeBankAccount, WithAuthorization, + getPayto } from "./harness.js"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; @@ -94,7 +95,7 @@ export async function createSimpleTestkudosEnvironment( }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -118,13 +119,13 @@ export async function createSimpleTestkudosEnvironment( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); @@ -186,7 +187,7 @@ export async function createFaultInjectedMerchantTestkudosEnvironment( const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -213,13 +214,13 @@ export async function createFaultInjectedMerchantTestkudosEnvironment( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); @@ -263,16 +264,19 @@ export async function startWithdrawViaBank( await wallet.runPending(); - // Confirm it - - await BankApi.confirmWithdrawalOperation(bank, user, wop); - - // Withdraw + // Withdraw (AKA select) await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, }); + + // Confirm it + + await BankApi.confirmWithdrawalOperation(bank, user, wop); + + await wallet.runPending(); + await wallet.runUntilDone(); } /** diff --git a/packages/taler-wallet-cli/src/harness/libeufin-apis.ts b/packages/taler-wallet-cli/src/harness/libeufin-apis.ts index 68a25d92f..13d27c467 100644 --- a/packages/taler-wallet-cli/src/harness/libeufin-apis.ts +++ b/packages/taler-wallet-cli/src/harness/libeufin-apis.ts @@ -184,7 +184,7 @@ export namespace LibeufinSandboxApi { libeufinSandboxService: LibeufinSandboxServiceInterface, accountLabel: string ) { - let url = new URL(`${libeufinSandboxService.baseUrl}/accounts/${accountLabel}`); + let url = new URL(`accounts/${accountLabel}`,libeufinSandboxService.baseUrl); return await axios.get(url.href, { auth: { username: username, @@ -199,7 +199,7 @@ export namespace LibeufinSandboxApi { password: string, libeufinSandboxService: LibeufinSandboxServiceInterface, ) { - let url = new URL(`${libeufinSandboxService.baseUrl}/testing/register`); + let url = new URL("testing/register", libeufinSandboxService.baseUrl); await axios.post(url.href, { username: username, password: password @@ -214,11 +214,11 @@ export namespace LibeufinSandboxApi { password: string = "secret", ) { // baseUrl should already be pointed to one demobank. - let url = new URL(libeufinSandboxService.baseUrl); + let url = new URL("ebics/subscribers", libeufinSandboxService.baseUrl); await axios.post(url.href, { userID: req.userID, hostID: req.hostID, - partnerID: req.userID, + partnerID: req.partnerID, demobankAccountLabel: demobankAccountLabel, }, { auth: { diff --git a/packages/taler-wallet-cli/src/harness/libeufin.ts b/packages/taler-wallet-cli/src/harness/libeufin.ts index d101efa52..0107d5a8b 100644 --- a/packages/taler-wallet-cli/src/harness/libeufin.ts +++ b/packages/taler-wallet-cli/src/harness/libeufin.ts @@ -36,8 +36,8 @@ import { runCommand, setupDb, sh, + getRandomIban } from "../harness/harness.js"; - import { LibeufinSandboxApi, LibeufinNexusApi, @@ -183,10 +183,6 @@ export interface LibeufinPreparedPaymentDetails { nexusBankAccountName: string; } -function getRandomIban(countryCode: string): string { - return `${countryCode}715001051796${(Math.random().toString().substring(2, 8))}` -} - export class LibeufinSandboxService implements LibeufinSandboxServiceInterface { static async create( gc: GlobalTestState, @@ -405,7 +401,7 @@ export class SandboxUserBundle { constructor(salt: string) { this.ebicsBankAccount = { bic: "BELADEBEXXX", - iban: getRandomIban("DE"), + iban: getRandomIban(), label: `remote-account-${salt}`, name: `Taler Exchange: ${salt}`, subscriber: { diff --git a/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts b/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts index 0f8af05e5..2259dd8bb 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-bank-api.ts @@ -27,6 +27,7 @@ import { BankApi, BankAccessApi, CreditDebitIndicator, + getPayto } from "../harness/harness.js"; import { createEddsaKeyPair, encodeCrock } from "@gnu-taler/taler-util"; import { defaultCoinConfig } from "../harness/denomStructures"; @@ -61,7 +62,7 @@ export async function runBankApiTest(t: GlobalTestState) { }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -85,13 +86,13 @@ export async function runBankApiTest(t: GlobalTestState) { await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts index f33c8338b..07382c43e 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-deposit.ts @@ -18,7 +18,7 @@ * Imports. */ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { GlobalTestState } from "../harness/harness.js"; +import { GlobalTestState, getPayto } from "../harness/harness.js"; import { createSimpleTestkudosEnvironment, withdrawViaBank } from "../harness/helpers.js"; /** @@ -44,7 +44,7 @@ export async function runDepositTest(t: GlobalTestState) { WalletApiOperation.CreateDepositGroup, { amount: "TESTKUDOS:10", - depositPaytoUri: "payto://x-taler-bank/localhost/foo", + depositPaytoUri: getPayto("foo"), }, ); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts index 8a5d563ce..91e9bdec5 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-exchange-management.ts @@ -26,6 +26,7 @@ import { MerchantService, BankApi, BankAccessApi, + getPayto } from "../harness/harness.js"; import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; import { @@ -69,7 +70,7 @@ export async function runExchangeManagementTest(t: GlobalTestState) { }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -98,13 +99,13 @@ export async function runExchangeManagementTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts b/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts index 56684f70a..9badfd501 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-exchange-timetravel.ts @@ -40,6 +40,7 @@ import { MerchantService, setupDb, WalletCli, + getPayto } from "../harness/harness.js"; import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js"; @@ -103,7 +104,7 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) { }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -127,13 +128,13 @@ export async function runExchangeTimetravelTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts b/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts index 025e12226..d3ff89ae4 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-fee-regression.ts @@ -25,6 +25,7 @@ import { MerchantService, setupDb, WalletCli, + getPayto } from "../harness/harness.js"; import { withdrawViaBank, @@ -63,7 +64,7 @@ export async function createMyTestkudosEnvironment( }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -140,7 +141,7 @@ export async function createMyTestkudosEnvironment( await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts index 8e8f966b9..1e958fd73 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-exchange-confusion.ts @@ -25,6 +25,7 @@ import { MerchantService, setupDb, WalletCli, + getPayto } from "../harness/harness.js"; import { withdrawViaBank, @@ -80,7 +81,7 @@ export async function createConfusedMerchantTestkudosEnvironment( const faultyExchange = new FaultInjectedExchangeService(t, exchange, 9081); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -108,13 +109,13 @@ export async function createConfusedMerchantTestkudosEnvironment( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")] }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts index 589c79120..ef926c4af 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-delete.ts @@ -25,6 +25,7 @@ import { MerchantApiClient, MerchantService, setupDb, + getPayto } from "../harness/harness.js"; /** @@ -74,7 +75,7 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, @@ -84,7 +85,7 @@ export async function runMerchantInstancesDeleteTest(t: GlobalTestState) { await merchant.addInstance({ id: "myinst", name: "Second Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts index fc5e7305a..6f76e2325 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances-urls.ts @@ -24,6 +24,7 @@ import { MerchantApiClient, MerchantService, setupDb, + getPayto } from "../harness/harness.js"; /** @@ -71,7 +72,7 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) { default_wire_transfer_delay: { d_ms: 60000 }, jurisdiction: {}, name: "My Default Instance", - payto_uris: ["payto://x-taler-bank/foo/bar"], + payto_uris: [getPayto("bar")], auth: { method: "token", token: "secret-token:i-am-default", @@ -88,7 +89,7 @@ export async function runMerchantInstancesUrlsTest(t: GlobalTestState) { default_wire_transfer_delay: { d_ms: 60000 }, jurisdiction: {}, name: "My Second Instance", - payto_uris: ["payto://x-taler-bank/foo/bar"], + payto_uris: [getPayto("bar")], auth: { method: "token", token: "secret-token:i-am-myinst", diff --git a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts index 46af87922..1bf6be4cd 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-merchant-instances.ts @@ -25,6 +25,7 @@ import { MerchantApiClient, MerchantService, setupDb, + getPayto } from "../harness/harness.js"; /** @@ -74,7 +75,7 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, @@ -84,7 +85,7 @@ export async function runMerchantInstancesTest(t: GlobalTestState) { await merchant.addInstance({ id: "myinst", name: "Second Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], auth: { method: "external", }, diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts index 2be01d919..7e421cc35 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-fault.ts @@ -31,6 +31,7 @@ import { MerchantPrivateApi, BankApi, BankAccessApi, + getPayto } from "../harness/harness.js"; import { FaultInjectedExchangeService, @@ -64,7 +65,7 @@ export async function runPaymentFaultTest(t: GlobalTestState) { }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); @@ -107,7 +108,7 @@ export async function runPaymentFaultTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); console.log("setup done!"); @@ -131,18 +132,21 @@ export async function runPaymentFaultTest(t: GlobalTestState) { await wallet.runPending(); - // Confirm it - - await BankApi.confirmWithdrawalOperation(bank, user, wop); - // Withdraw await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: faultyExchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, }); + await wallet.runPending(); + + // Confirm it + + await BankApi.confirmWithdrawalOperation(bank, user, wop); + await wallet.runUntilDone(); + // Check balance await wallet.client.call(WalletApiOperation.GetBalances, {}); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts b/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts index 754c3a0e8..3084ecfe0 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-payment-multiple.ts @@ -25,6 +25,7 @@ import { MerchantService, WalletCli, MerchantPrivateApi, + getPayto } from "../harness/harness.js"; import { withdrawViaBank } from "../harness/helpers.js"; import { coin_ct10, coin_u1 } from "../harness/denomStructures"; @@ -54,7 +55,7 @@ async function setupTest( }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); @@ -86,13 +87,13 @@ async function setupTest( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts index 276c532b5..87c4d958b 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-revocation.ts @@ -27,6 +27,7 @@ import { setupDb, BankService, delayMs, + getPayto } from "../harness/harness.js"; import { withdrawViaBank, @@ -84,7 +85,7 @@ async function createTestEnvironment( }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -121,13 +122,13 @@ async function createTestEnvironment( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts index e20d8bdad..b55be9f82 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-timetravel-autorefresh.ts @@ -36,6 +36,7 @@ import { MerchantService, setupDb, WalletCli, + getPayto } from "../harness/harness.js"; import { startWithdrawViaBank, withdrawViaBank } from "../harness/helpers.js"; @@ -97,7 +98,7 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -121,13 +122,13 @@ export async function runTimetravelAutorefreshTest(t: GlobalTestState) { await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); await merchant.addInstance({ id: "minst1", name: "minst1", - paytoUris: ["payto://x-taler-bank/minst1"], + paytoUris: [getPayto("minst1")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts b/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts index c6a7f8402..f31220e24 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-tipping.ts @@ -18,7 +18,7 @@ * Imports. */ import { WalletApiOperation } from "@gnu-taler/taler-wallet-core"; -import { GlobalTestState, MerchantPrivateApi, BankApi } from "../harness/harness.js"; +import { GlobalTestState, MerchantPrivateApi, BankApi, getWireMethod } from "../harness/harness.js"; import { createSimpleTestkudosEnvironment } from "../harness/helpers.js"; /** @@ -43,7 +43,7 @@ export async function runTippingTest(t: GlobalTestState) { { exchange_url: exchange.baseUrl, initial_balance: "TESTKUDOS:10", - wire_method: "x-taler-bank", + wire_method: getWireMethod(), }, ); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts b/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts index c21a7279b..c42ae5adf 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-wallettesting.ts @@ -32,6 +32,7 @@ import { MerchantService, setupDb, WalletCli, + getPayto } from "../harness/harness.js"; import { SimpleTestEnvironment } from "../harness/helpers.js"; @@ -69,7 +70,7 @@ export async function createMyEnvironment( }); const exchangeBankAccount = await bank.createExchangeAccount( - "MyExchange", + "myexchange", "x", ); exchange.addBankAccount("1", exchangeBankAccount); @@ -93,7 +94,7 @@ export async function createMyEnvironment( await merchant.addInstance({ id: "default", name: "Default Instance", - paytoUris: [`payto://x-taler-bank/merchant-default`], + paytoUris: [getPayto("merchant-default")], }); console.log("setup done!"); diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts index fe719ea62..5ba1fa893 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-abort-bank.ts @@ -47,12 +47,18 @@ export async function runWithdrawalAbortBankTest(t: GlobalTestState) { await wallet.runPending(); - // Confirm it + // Abort it await BankApi.abortWithdrawalOperation(bank, user, wop); // Withdraw + // Difference: + // -> with euFin, the wallet selects + // -> with PyBank, the wallet stops _before_ + // + // WHY ?! + // const e = await t.assertThrowsOperationErrorAsync(async () => { await wallet.client.call( WalletApiOperation.AcceptBankIntegratedWithdrawal, diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts index 35969c78f..25df19e46 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-bank-integrated.ts @@ -47,16 +47,18 @@ export async function runWithdrawalBankIntegratedTest(t: GlobalTestState) { await wallet.runPending(); - // Confirm it - - await BankApi.confirmWithdrawalOperation(bank, user, wop); - // Withdraw const r2 = await wallet.client.call(WalletApiOperation.AcceptBankIntegratedWithdrawal, { exchangeBaseUrl: exchange.baseUrl, talerWithdrawUri: wop.taler_withdraw_uri, }); + await wallet.runPending(); + + // Confirm it + + await BankApi.confirmWithdrawalOperation(bank, user, wop); + await wallet.runUntilDone(); // Check balance diff --git a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts index b93d1b500..2f88b3024 100644 --- a/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts +++ b/packages/taler-wallet-cli/src/integrationtests/test-withdrawal-manual.ts @@ -50,6 +50,7 @@ export async function runTestWithdrawalManualTest(t: GlobalTestState) { const reservePub: string = wres.reservePub; + // Bug. await BankApi.adminAddIncoming(bank, { exchangeBankAccount, amount: "TESTKUDOS:10", diff --git a/packages/taler-wallet-core/package.json b/packages/taler-wallet-core/package.json index d8b344f2c..3f20811ff 100644 --- a/packages/taler-wallet-core/package.json +++ b/packages/taler-wallet-core/package.json @@ -58,7 +58,7 @@ "rollup-plugin-sourcemaps": "^0.6.3", "source-map-resolve": "^0.6.0", "typedoc": "^0.20.16", - "typescript": "^4.1.3" + "typescript": "^4.4.4" }, "dependencies": { "@gnu-taler/idb-bridge": "workspace:*", diff --git a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts index 922fbbfac..7d616ecb6 100644 --- a/packages/taler-wallet-core/src/crypto/cryptoTypes.ts +++ b/packages/taler-wallet-core/src/crypto/cryptoTypes.ts @@ -27,13 +27,13 @@ /** * Imports. */ -import { AmountJson } from "@gnu-taler/taler-util"; +import { AmountJson, DenominationPubKey } from "@gnu-taler/taler-util"; export interface RefreshNewDenomInfo { count: number; value: AmountJson; feeWithdraw: AmountJson; - denomPub: string; + denomPub: DenominationPubKey; } /** @@ -117,7 +117,7 @@ export interface DerivedRefreshSession { export interface DeriveTipRequest { secretSeed: string; - denomPub: string; + denomPub: DenominationPubKey; planchetIndex: number; } diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts index 6bace01a3..e6c0290f1 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoApi.ts @@ -24,7 +24,7 @@ */ import { CoinRecord, DenominationRecord, WireFee } from "../../db.js"; -import { CryptoWorker } from "./cryptoWorker.js"; +import { CryptoWorker } from "./cryptoWorkerInterface.js"; import { RecoupRequest, CoinDepositPermission } from "@gnu-taler/taler-util"; diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts index c42ece778..389b98b22 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoImplementation.ts @@ -15,9 +15,7 @@ */ /** - * Synchronous implementation of crypto-related functions for the wallet. - * - * The functionality is parameterized over an Emscripten environment. + * Implementation of crypto-related high-level functions for the Taler wallet. * * @author Florian Dold <dold@taler.net> */ @@ -37,9 +35,11 @@ import { import { buildSigPS, CoinDepositPermission, + DenomKeyType, + FreshCoin, + hashDenomPub, RecoupRequest, RefreshPlanchetInfo, - SignaturePurposeBuilder, TalerSignaturePurpose, } from "@gnu-taler/taler-util"; // FIXME: These types should be internal to the wallet! @@ -128,25 +128,46 @@ function timestampRoundedToBuffer(ts: Timestamp): Uint8Array { return new Uint8Array(b); } +export interface PrimitiveWorker { + setupRefreshPlanchet(arg0: { + transfer_secret: string; + coin_index: number; + }): Promise<{ + coin_pub: string; + coin_priv: string; + blinding_key: string; + }>; + eddsaVerify(req: { + msg: string; + sig: string; + pub: string; + }): Promise<{ valid: boolean }>; +} + export class CryptoImplementation { static enableTracing = false; + constructor(private primitiveWorker?: PrimitiveWorker) {} + /** * Create a pre-coin of the given denomination to be withdrawn from then given * reserve. */ createPlanchet(req: PlanchetCreationRequest): PlanchetCreationResult { + if (req.denomPub.cipher !== 1) { + throw Error("unsupported cipher"); + } const reservePub = decodeCrock(req.reservePub); const reservePriv = decodeCrock(req.reservePriv); - const denomPub = decodeCrock(req.denomPub); + const denomPubRsa = decodeCrock(req.denomPub.rsa_public_key); const derivedPlanchet = setupWithdrawPlanchet( decodeCrock(req.secretSeed), req.coinIndex, ); const coinPubHash = hash(derivedPlanchet.coinPub); - const ev = rsaBlind(coinPubHash, derivedPlanchet.bks, denomPub); + const ev = rsaBlind(coinPubHash, derivedPlanchet.bks, denomPubRsa); const amountWithFee = Amounts.add(req.value, req.feeWithdraw).amount; - const denomPubHash = hash(denomPub); + const denomPubHash = hashDenomPub(req.denomPub); const evHash = hash(ev); const withdrawRequest = buildSigPS( @@ -166,7 +187,10 @@ export class CryptoImplementation { coinPriv: encodeCrock(derivedPlanchet.coinPriv), coinPub: encodeCrock(derivedPlanchet.coinPub), coinValue: req.value, - denomPub: encodeCrock(denomPub), + denomPub: { + cipher: 1, + rsa_public_key: encodeCrock(denomPubRsa), + }, denomPubHash: encodeCrock(denomPubHash), reservePub: encodeCrock(reservePub), withdrawSig: encodeCrock(sig), @@ -179,8 +203,11 @@ export class CryptoImplementation { * Create a planchet used for tipping, including the private keys. */ createTipPlanchet(req: DeriveTipRequest): DerivedTipPlanchet { + if (req.denomPub.cipher !== 1) { + throw Error("unsupported cipher"); + } const fc = setupTipPlanchet(decodeCrock(req.secretSeed), req.planchetIndex); - const denomPub = decodeCrock(req.denomPub); + const denomPub = decodeCrock(req.denomPub.rsa_public_key); const coinPubHash = hash(fc.coinPub); const ev = rsaBlind(coinPubHash, fc.bks, denomPub); @@ -246,7 +273,11 @@ export class CryptoImplementation { /** * Check if a wire fee is correctly signed. */ - isValidWireFee(type: string, wf: WireFee, masterPub: string): boolean { + async isValidWireFee( + type: string, + wf: WireFee, + masterPub: string, + ): Promise<boolean> { const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_FEES) .put(hash(stringToBytes(type + "\0"))) .put(timestampRoundedToBuffer(wf.startStamp)) @@ -256,13 +287,25 @@ export class CryptoImplementation { .build(); const sig = decodeCrock(wf.sig); const pub = decodeCrock(masterPub); + if (this.primitiveWorker) { + return ( + await this.primitiveWorker.eddsaVerify({ + msg: encodeCrock(p), + pub: masterPub, + sig: encodeCrock(sig), + }) + ).valid; + } return eddsaVerify(p, sig, pub); } /** * Check if the signature of a denomination is valid. */ - isValidDenom(denom: DenominationRecord, masterPub: string): boolean { + async isValidDenom( + denom: DenominationRecord, + masterPub: string, + ): Promise<boolean> { const p = buildSigPS(TalerSignaturePurpose.MASTER_DENOMINATION_KEY_VALIDITY) .put(decodeCrock(masterPub)) .put(timestampRoundedToBuffer(denom.stampStart)) @@ -287,14 +330,9 @@ export class CryptoImplementation { sig: string, masterPub: string, ): boolean { - const h = kdf( - 64, - stringToBytes("exchange-wire-signature"), - stringToBytes(paytoUri + "\0"), - new Uint8Array(0), - ); + const paytoHash = hash(stringToBytes(paytoUri + "\0")); const p = buildSigPS(TalerSignaturePurpose.MASTER_WIRE_DETAILS) - .put(h) + .put(paytoHash) .build(); return eddsaVerify(p, decodeCrock(sig), decodeCrock(masterPub)); } @@ -353,8 +391,11 @@ export class CryptoImplementation { * and deposit permissions for each given coin. */ signDepositPermission(depositInfo: DepositInfo): CoinDepositPermission { + // FIXME: put extensions here if used + const hExt = new Uint8Array(64); const d = buildSigPS(TalerSignaturePurpose.WALLET_COIN_DEPOSIT) .put(decodeCrock(depositInfo.contractTermsHash)) + .put(hExt) .put(decodeCrock(depositInfo.wireInfoHash)) .put(decodeCrock(depositInfo.denomPubHash)) .put(timestampRoundedToBuffer(depositInfo.timestamp)) @@ -362,7 +403,6 @@ export class CryptoImplementation { .put(amountToBuffer(depositInfo.spendAmount)) .put(amountToBuffer(depositInfo.feeDeposit)) .put(decodeCrock(depositInfo.merchantPub)) - .put(decodeCrock(depositInfo.coinPub)) .build(); const coinSig = eddsaSign(d, decodeCrock(depositInfo.coinPriv)); @@ -372,14 +412,17 @@ export class CryptoImplementation { contribution: Amounts.stringify(depositInfo.spendAmount), h_denom: depositInfo.denomPubHash, exchange_url: depositInfo.exchangeBaseUrl, - ub_sig: depositInfo.denomSig, + ub_sig: { + cipher: DenomKeyType.Rsa, + rsa_signature: depositInfo.denomSig.rsa_signature, + }, }; return s; } - deriveRefreshSession( + async deriveRefreshSession( req: DeriveRefreshSessionRequest, - ): DerivedRefreshSession { + ): Promise<DerivedRefreshSession> { const { newCoinDenoms, feeRefresh: meltFee, @@ -423,8 +466,10 @@ export class CryptoImplementation { for (const denomSel of newCoinDenoms) { for (let i = 0; i < denomSel.count; i++) { - const r = decodeCrock(denomSel.denomPub); - sessionHc.update(r); + if (denomSel.denomPub.cipher !== 1) { + throw Error("unsupported cipher"); + } + sessionHc.update(hashDenomPub(denomSel.denomPub)); } } @@ -435,19 +480,38 @@ export class CryptoImplementation { for (let j = 0; j < newCoinDenoms.length; j++) { const denomSel = newCoinDenoms[j]; for (let k = 0; k < denomSel.count; k++) { - const coinNumber = planchets.length; + const coinIndex = planchets.length; const transferPriv = decodeCrock(transferPrivs[i]); const oldCoinPub = decodeCrock(meltCoinPub); const transferSecret = keyExchangeEcdheEddsa( transferPriv, oldCoinPub, ); - const fresh = setupRefreshPlanchet(transferSecret, coinNumber); - const coinPriv = fresh.coinPriv; - const coinPub = fresh.coinPub; - const blindingFactor = fresh.bks; + let coinPub: Uint8Array; + let coinPriv: Uint8Array; + let blindingFactor: Uint8Array; + if (this.primitiveWorker) { + const r = await this.primitiveWorker.setupRefreshPlanchet({ + transfer_secret: encodeCrock(transferSecret), + coin_index: coinIndex, + }); + coinPub = decodeCrock(r.coin_pub); + coinPriv = decodeCrock(r.coin_priv); + blindingFactor = decodeCrock(r.blinding_key); + } else { + let fresh: FreshCoin = setupRefreshPlanchet( + transferSecret, + coinIndex, + ); + coinPriv = fresh.coinPriv; + coinPub = fresh.coinPub; + blindingFactor = fresh.bks; + } const pubHash = hash(coinPub); - const denomPub = decodeCrock(denomSel.denomPub); + if (denomSel.denomPub.cipher !== 1) { + throw Error("unsupported cipher"); + } + const denomPub = decodeCrock(denomSel.denomPub.rsa_public_key); const ev = rsaBlind(pubHash, blindingFactor, denomPub); const planchet: RefreshPlanchetInfo = { blindingKey: encodeCrock(blindingFactor), diff --git a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts index 9f3ee6f50..9f3ee6f50 100644 --- a/packages/taler-wallet-core/src/crypto/workers/cryptoWorker.ts +++ b/packages/taler-wallet-core/src/crypto/workers/cryptoWorkerInterface.ts diff --git a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts index 3f7f9e170..df57635d1 100644 --- a/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts +++ b/packages/taler-wallet-core/src/crypto/workers/nodeThreadWorker.ts @@ -18,7 +18,7 @@ * Imports */ import { CryptoWorkerFactory } from "./cryptoApi.js"; -import { CryptoWorker } from "./cryptoWorker.js"; +import { CryptoWorker } from "./cryptoWorkerInterface.js"; import os from "os"; import { CryptoImplementation } from "./cryptoImplementation.js"; import { Logger } from "@gnu-taler/taler-util"; @@ -94,7 +94,7 @@ export function handleWorkerMessage(msg: any): void { } try { - const result = (impl as any)[operation](...args); + const result = await (impl as any)[operation](...args); // eslint-disable-next-line @typescript-eslint/no-var-requires const _r = "require"; const worker_threads: typeof import("worker_threads") = module[_r]( diff --git a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts b/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts index f6b8ac5d7..8293bb369 100644 --- a/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts +++ b/packages/taler-wallet-core/src/crypto/workers/synchronousWorker.ts @@ -14,10 +14,107 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { CryptoImplementation } from "./cryptoImplementation.js"; +import { + CryptoImplementation, + PrimitiveWorker, +} from "./cryptoImplementation.js"; import { CryptoWorkerFactory } from "./cryptoApi.js"; -import { CryptoWorker } from "./cryptoWorker.js"; +import { CryptoWorker } from "./cryptoWorkerInterface.js"; + +import child_process from "child_process"; +import type internal from "stream"; +import { OpenedPromise, openPromise } from "../../index.js"; +import { FreshCoin, Logger } from "@gnu-taler/taler-util"; + +const logger = new Logger("synchronousWorker.ts"); + +class MyPrimitiveWorker implements PrimitiveWorker { + proc: child_process.ChildProcessByStdio< + internal.Writable, + internal.Readable, + null + >; + requests: Array<{ + p: OpenedPromise<any>; + req: any; + }> = []; + + constructor() { + const stdoutChunks: Buffer[] = []; + this.proc = child_process.spawn("taler-crypto-worker", { + //stdio: ["pipe", "pipe", "inherit"], + stdio: ["pipe", "pipe", "inherit"], + detached: true, + }); + this.proc.on("close", function (code) { + logger.error("child process exited"); + }); + (this.proc.stdout as any).unref(); + (this.proc.stdin as any).unref(); + this.proc.unref(); + + this.proc.stdout.on("data", (x) => { + // console.log("got chunk", x.toString("utf-8")); + if (x instanceof Buffer) { + const nlIndex = x.indexOf("\n"); + if (nlIndex >= 0) { + const before = x.slice(0, nlIndex); + const after = x.slice(nlIndex + 1); + stdoutChunks.push(after); + const str = Buffer.concat([...stdoutChunks, before]).toString( + "utf-8", + ); + const req = this.requests.shift()!; + if (this.requests.length === 0) { + this.proc.unref(); + } + //logger.info(`got response: ${str}`); + req.p.resolve(JSON.parse(str)); + } else { + stdoutChunks.push(x); + } + } else { + throw Error(`unexpected data chunk type (${typeof x})`); + } + }); + } + + async setupRefreshPlanchet(req: { + transfer_secret: string; + coin_index: number; + }): Promise<{ + coin_pub: string; + coin_priv: string; + blinding_key: string; + }> { + return this.queueRequest({ + op: "setup_refresh_planchet", + args: req, + }); + } + + async queueRequest(req: any): Promise<any> { + const p = openPromise<any>(); + if (this.requests.length === 0) { + this.proc.ref(); + } + this.requests.push({ req, p }); + this.proc.stdin.write(JSON.stringify(req) + "\n"); + return p.promise; + } + + async eddsaVerify(req: { + msg: string; + sig: string; + pub: string; + }): Promise<{ valid: boolean }> { + return this.queueRequest({ + op: "eddsa_verify", + args: req, + }); + } +} /** * The synchronous crypto worker produced by this factory doesn't run in the @@ -50,9 +147,14 @@ export class SynchronousCryptoWorker { */ onerror: undefined | ((m: any) => void); + primitiveWorker: PrimitiveWorker; + constructor() { this.onerror = undefined; this.onmessage = undefined; + if (process.env["TALER_WALLET_PRIMITIVE_WORKER"]) { + this.primitiveWorker = new MyPrimitiveWorker(); + } } /** @@ -80,7 +182,7 @@ export class SynchronousCryptoWorker { id: number, args: string[], ): Promise<void> { - const impl = new CryptoImplementation(); + const impl = new CryptoImplementation(this.primitiveWorker); if (!(operation in impl)) { console.error(`crypto operation '${operation}' not found`); @@ -89,16 +191,16 @@ export class SynchronousCryptoWorker { let result: any; try { - result = (impl as any)[operation](...args); + result = await (impl as any)[operation](...args); } catch (e) { - console.log("error during operation", e); + logger.error("error during operation", e); return; } try { setTimeout(() => this.dispatchMessage({ result, id }), 0); } catch (e) { - console.log("got error during dispatch", e); + logger.error("got error during dispatch", e); } } diff --git a/packages/taler-wallet-core/src/db.ts b/packages/taler-wallet-core/src/db.ts index 902f749cf..483cb16c2 100644 --- a/packages/taler-wallet-core/src/db.ts +++ b/packages/taler-wallet-core/src/db.ts @@ -28,6 +28,7 @@ import { Auditor, CoinDepositPermission, ContractTerms, + DenominationPubKey, Duration, ExchangeSignKeyJson, InternationalizedString, @@ -36,6 +37,7 @@ import { RefreshReason, TalerErrorDetails, Timestamp, + UnblindedSignature, } from "@gnu-taler/taler-util"; import { RetryInfo } from "./util/retries.js"; import { PayCoinSelection } from "./util/coinSelection.js"; @@ -310,7 +312,7 @@ export interface DenominationRecord { /** * The denomination public key. */ - denomPub: string; + denomPub: DenominationPubKey; /** * Hash of the denomination public key. @@ -452,7 +454,7 @@ export interface ExchangeDetailsRecord { /** * content-type of the last downloaded termsOfServiceText. */ - termsOfServiceContentType: string | undefined; + termsOfServiceContentType: string | undefined; /** * ETag for last terms of service download. @@ -578,7 +580,8 @@ export interface PlanchetRecord { denomPubHash: string; - denomPub: string; + // FIXME: maybe too redundant? + denomPub: DenominationPubKey; blindingKey: string; @@ -668,7 +671,7 @@ export interface CoinRecord { /** * Key used by the exchange used to sign the coin. */ - denomPub: string; + denomPub: DenominationPubKey; /** * Hash of the public key that signs the coin. @@ -678,7 +681,7 @@ export interface CoinRecord { /** * Unblinded signature by the exchange. */ - denomSig: string; + denomSig: UnblindedSignature; /** * Amount that's left on the coin. diff --git a/packages/taler-wallet-core/src/errors.ts b/packages/taler-wallet-core/src/errors.ts index d788405ff..3109644ac 100644 --- a/packages/taler-wallet-core/src/errors.ts +++ b/packages/taler-wallet-core/src/errors.ts @@ -93,7 +93,7 @@ export async function guardOperationException<T>( ): Promise<T> { try { return await op(); - } catch (e) { + } catch (e: any) { if (e instanceof OperationFailedAndReportedError) { throw e; } diff --git a/packages/taler-wallet-core/src/headless/helpers.ts b/packages/taler-wallet-core/src/headless/helpers.ts index f2285e149..191c48441 100644 --- a/packages/taler-wallet-core/src/headless/helpers.ts +++ b/packages/taler-wallet-core/src/headless/helpers.ts @@ -142,19 +142,23 @@ export async function getDefaultNodeWallet( const myDb = await openTalerDatabase(myIdbFactory, myVersionChange); let workerFactory; - try { - // Try if we have worker threads available, fails in older node versions. - const _r = "require"; - const worker_threads = module[_r]("worker_threads"); - // require("worker_threads"); - workerFactory = new NodeThreadCryptoWorkerFactory(); - } catch (e) { - logger.warn( - "worker threads not available, falling back to synchronous workers", - ); + if (process.env["TALER_WALLET_SYNC_CRYPTO"]) { + logger.info("using synchronous crypto worker"); workerFactory = new SynchronousCryptoWorkerFactory(); + } else { + try { + // Try if we have worker threads available, fails in older node versions. + const _r = "require"; + const worker_threads = module[_r]("worker_threads"); + // require("worker_threads"); + workerFactory = new NodeThreadCryptoWorkerFactory(); + } catch (e) { + logger.warn( + "worker threads not available, falling back to synchronous workers", + ); + workerFactory = new SynchronousCryptoWorkerFactory(); + } } - const w = await Wallet.create(myDb, myHttpLib, workerFactory); if (args.notifyHandler) { diff --git a/packages/taler-wallet-core/src/index.ts b/packages/taler-wallet-core/src/index.ts index 0b360a248..5489bd5a3 100644 --- a/packages/taler-wallet-core/src/index.ts +++ b/packages/taler-wallet-core/src/index.ts @@ -34,7 +34,7 @@ export * from "./db-utils.js"; // Crypto and crypto workers // export * from "./crypto/workers/nodeThreadWorker.js"; export { CryptoImplementation } from "./crypto/workers/cryptoImplementation.js"; -export type { CryptoWorker } from "./crypto/workers/cryptoWorker.js"; +export type { CryptoWorker } from "./crypto/workers/cryptoWorkerInterface.js"; export { CryptoWorkerFactory, CryptoApi } from "./crypto/workers/cryptoApi.js"; export * from "./pending-types.js"; diff --git a/packages/taler-wallet-core/src/operations/backup/import.ts b/packages/taler-wallet-core/src/operations/backup/import.ts index 7623ab189..e8e1de0b9 100644 --- a/packages/taler-wallet-core/src/operations/backup/import.ts +++ b/packages/taler-wallet-core/src/operations/backup/import.ts @@ -202,7 +202,7 @@ export interface CompletedCoin { * as the async crypto worker communication would auto-close the database transaction. */ export interface BackupCryptoPrecomputedData { - denomPubToHash: Record<string, string>; + rsaDenomPubToHash: Record<string, string>; coinPrivToCompletedCoin: Record<string, CompletedCoin>; proposalNoncePrivToPub: { [priv: string]: string }; proposalIdToContractTermsHash: { [proposalId: string]: string }; @@ -330,8 +330,13 @@ export async function importBackup( } for (const backupDenomination of backupExchangeDetails.denominations) { + if (backupDenomination.denom_pub.cipher !== 1) { + throw Error("unsupported cipher"); + } const denomPubHash = - cryptoComp.denomPubToHash[backupDenomination.denom_pub]; + cryptoComp.rsaDenomPubToHash[ + backupDenomination.denom_pub.rsa_public_key + ]; checkLogicInvariant(!!denomPubHash); const existingDenom = await tx.denominations.get([ backupExchangeDetails.base_url, diff --git a/packages/taler-wallet-core/src/operations/backup/index.ts b/packages/taler-wallet-core/src/operations/backup/index.ts index 3f4c02274..9027625cd 100644 --- a/packages/taler-wallet-core/src/operations/backup/index.ts +++ b/packages/taler-wallet-core/src/operations/backup/index.ts @@ -40,6 +40,7 @@ import { ConfirmPayResultType, durationFromSpec, getTimestampNow, + hashDenomPub, HttpStatusCode, j2s, Logger, @@ -57,10 +58,7 @@ import { import { gunzipSync, gzipSync } from "fflate"; import { InternalWalletState } from "../../common.js"; import { kdf } from "@gnu-taler/taler-util"; -import { - secretbox, - secretbox_open, -} from "@gnu-taler/taler-util"; +import { secretbox, secretbox_open } from "@gnu-taler/taler-util"; import { bytesToString, decodeCrock, @@ -162,13 +160,16 @@ async function computeBackupCryptoData( ): Promise<BackupCryptoPrecomputedData> { const cryptoData: BackupCryptoPrecomputedData = { coinPrivToCompletedCoin: {}, - denomPubToHash: {}, + rsaDenomPubToHash: {}, proposalIdToContractTermsHash: {}, proposalNoncePrivToPub: {}, reservePrivToPub: {}, }; for (const backupExchangeDetails of backupContent.exchange_details) { for (const backupDenom of backupExchangeDetails.denominations) { + if (backupDenom.denom_pub.cipher !== 1) { + throw Error("unsupported cipher"); + } for (const backupCoin of backupDenom.coins) { const coinPub = encodeCrock( eddsaGetPublic(decodeCrock(backupCoin.coin_priv)), @@ -176,16 +177,16 @@ async function computeBackupCryptoData( const blindedCoin = rsaBlind( hash(decodeCrock(backupCoin.coin_priv)), decodeCrock(backupCoin.blinding_key), - decodeCrock(backupDenom.denom_pub), + decodeCrock(backupDenom.denom_pub.rsa_public_key), ); cryptoData.coinPrivToCompletedCoin[backupCoin.coin_priv] = { coinEvHash: encodeCrock(hash(blindedCoin)), coinPub, }; } - cryptoData.denomPubToHash[backupDenom.denom_pub] = encodeCrock( - hash(decodeCrock(backupDenom.denom_pub)), - ); + cryptoData.rsaDenomPubToHash[ + backupDenom.denom_pub.rsa_public_key + ] = encodeCrock(hashDenomPub(backupDenom.denom_pub)); } for (const backupReserve of backupExchangeDetails.reserves) { cryptoData.reservePrivToPub[backupReserve.reserve_priv] = encodeCrock( diff --git a/packages/taler-wallet-core/src/operations/deposits.ts b/packages/taler-wallet-core/src/operations/deposits.ts index 740242050..8fe3702f5 100644 --- a/packages/taler-wallet-core/src/operations/deposits.ts +++ b/packages/taler-wallet-core/src/operations/deposits.ts @@ -25,6 +25,7 @@ import { ContractTerms, CreateDepositGroupRequest, CreateDepositGroupResponse, + decodeCrock, durationFromSpec, getTimestampNow, Logger, @@ -106,7 +107,7 @@ function hashWire(paytoUri: string, salt: string): string { const r = kdf( 64, stringToBytes(paytoUri + "\0"), - stringToBytes(salt + "\0"), + decodeCrock(salt), stringToBytes("merchant-wire-signature"), ); return encodeCrock(r); @@ -213,8 +214,8 @@ async function processDepositGroupImpl( const url = new URL(`coins/${perm.coin_pub}/deposit`, perm.exchange_url); const httpResp = await ws.http.postJson(url.href, { contribution: Amounts.stringify(perm.contribution), - wire: depositGroup.wire, - h_wire: depositGroup.contractTermsRaw.h_wire, + merchant_payto_uri: depositGroup.wire.payto_uri, + wire_salt: depositGroup.wire.salt, h_contract_terms: depositGroup.contractTermsHash, ub_sig: perm.ub_sig, timestamp: depositGroup.contractTermsRaw.timestamp, @@ -355,7 +356,7 @@ export async function createDepositGroup( const timestampRound = timestampTruncateToSecond(timestamp); const noncePair = await ws.cryptoApi.createEddsaKeypair(); const merchantPair = await ws.cryptoApi.createEddsaKeypair(); - const wireSalt = encodeCrock(getRandomBytes(64)); + const wireSalt = encodeCrock(getRandomBytes(16)); const wireHash = hashWire(req.depositPaytoUri, wireSalt); const contractTerms: ContractTerms = { auditors: [], diff --git a/packages/taler-wallet-core/src/operations/exchanges.ts b/packages/taler-wallet-core/src/operations/exchanges.ts index 629957efb..c170c5469 100644 --- a/packages/taler-wallet-core/src/operations/exchanges.ts +++ b/packages/taler-wallet-core/src/operations/exchanges.ts @@ -39,6 +39,7 @@ import { URL, TalerErrorDetails, Timestamp, + hashDenomPub, } from "@gnu-taler/taler-util"; import { decodeCrock, encodeCrock, hash } from "@gnu-taler/taler-util"; import { CryptoApi } from "../crypto/workers/cryptoApi.js"; @@ -78,7 +79,7 @@ function denominationRecordFromKeys( listIssueDate: Timestamp, denomIn: Denomination, ): DenominationRecord { - const denomPubHash = encodeCrock(hash(decodeCrock(denomIn.denom_pub))); + const denomPubHash = encodeCrock(hashDenomPub(denomIn.denom_pub)); const d: DenominationRecord = { denomPub: denomIn.denom_pub, denomPubHash, @@ -472,26 +473,29 @@ async function updateExchangeFromUrlImpl( let tosFound: ExchangeTosDownloadResult | undefined; //Remove this when exchange supports multiple content-type in accept header - if (acceptedFormat) for (const format of acceptedFormat) { - const resp = await downloadExchangeWithTermsOfService( - baseUrl, - ws.http, - timeout, - format - ); - if (resp.tosContentType === format) { - tosFound = resp - break + if (acceptedFormat) + for (const format of acceptedFormat) { + const resp = await downloadExchangeWithTermsOfService( + baseUrl, + ws.http, + timeout, + format, + ); + if (resp.tosContentType === format) { + tosFound = resp; + break; + } } - } // If none of the specified format was found try text/plain - const tosDownload = tosFound !== undefined ? tosFound : - await downloadExchangeWithTermsOfService( - baseUrl, - ws.http, - timeout, - "text/plain" - ); + const tosDownload = + tosFound !== undefined + ? tosFound + : await downloadExchangeWithTermsOfService( + baseUrl, + ws.http, + timeout, + "text/plain", + ); let recoupGroupId: string | undefined = undefined; diff --git a/packages/taler-wallet-core/src/operations/pay.ts b/packages/taler-wallet-core/src/operations/pay.ts index a42480f40..acc592a72 100644 --- a/packages/taler-wallet-core/src/operations/pay.ts +++ b/packages/taler-wallet-core/src/operations/pay.ts @@ -175,7 +175,7 @@ export async function getEffectiveDepositAmount( for (let i = 0; i < pcs.coinPubs.length; i++) { const coin = await tx.coins.get(pcs.coinPubs[i]); if (!coin) { - throw Error("can't calculate deposit amountt, coin not found"); + throw Error("can't calculate deposit amount, coin not found"); } const denom = await tx.denominations.get([ coin.exchangeBaseUrl, @@ -193,6 +193,9 @@ export async function getEffectiveDepositAmount( if (!exchangeDetails) { continue; } + // FIXME/NOTE: the line below _likely_ throws exception + // about "find method not found on undefined" when the wireType + // is not supported by the Exchange. const fee = exchangeDetails.wireInfo.feesForType[wireType].find((x) => { return timestampIsBetween( getTimestampNow(), diff --git a/packages/taler-wallet-core/src/operations/refresh.ts b/packages/taler-wallet-core/src/operations/refresh.ts index d727bd06f..956e4d65a 100644 --- a/packages/taler-wallet-core/src/operations/refresh.ts +++ b/packages/taler-wallet-core/src/operations/refresh.ts @@ -14,7 +14,12 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { encodeCrock, getRandomBytes, HttpStatusCode } from "@gnu-taler/taler-util"; +import { + DenomKeyType, + encodeCrock, + getRandomBytes, + HttpStatusCode, +} from "@gnu-taler/taler-util"; import { CoinRecord, CoinSourceType, @@ -599,10 +604,17 @@ async function refreshReveal( continue; } const pc = derived.planchetsForGammas[norevealIndex][newCoinIndex]; - const denomSig = await ws.cryptoApi.rsaUnblind( - reveal.ev_sigs[newCoinIndex].ev_sig, + if (denom.denomPub.cipher !== 1) { + throw Error("cipher unsupported"); + } + const evSig = reveal.ev_sigs[newCoinIndex].ev_sig; + if (evSig.cipher !== DenomKeyType.Rsa) { + throw Error("unsupported cipher"); + } + const denomSigRsa = await ws.cryptoApi.rsaUnblind( + evSig.blinded_rsa_signature, pc.blindingKey, - denom.denomPub, + denom.denomPub.rsa_public_key, ); const coin: CoinRecord = { blindingKey: pc.blindingKey, @@ -611,7 +623,10 @@ async function refreshReveal( currentAmount: denom.value, denomPub: denom.denomPub, denomPubHash: denom.denomPubHash, - denomSig, + denomSig: { + cipher: DenomKeyType.Rsa, + rsa_signature: denomSigRsa, + }, exchangeBaseUrl: oldCoin.exchangeBaseUrl, status: CoinStatus.Fresh, coinSource: { diff --git a/packages/taler-wallet-core/src/operations/testing.ts b/packages/taler-wallet-core/src/operations/testing.ts index d2071cd53..d6f0626dd 100644 --- a/packages/taler-wallet-core/src/operations/testing.ts +++ b/packages/taler-wallet-core/src/operations/testing.ts @@ -174,7 +174,8 @@ async function registerRandomBankUser( const reqUrl = new URL("testing/register", bankBaseUrl).href; const randId = makeId(8); const bankUser: BankUser = { - username: `testuser-${randId}`, + // euFin doesn't allow resource names to have upper case letters. + username: `testuser-${randId.toLowerCase()}`, password: `testpw-${randId}`, }; diff --git a/packages/taler-wallet-core/src/operations/tip.ts b/packages/taler-wallet-core/src/operations/tip.ts index a90e5270f..07ce00d2e 100644 --- a/packages/taler-wallet-core/src/operations/tip.ts +++ b/packages/taler-wallet-core/src/operations/tip.ts @@ -30,6 +30,7 @@ import { codecForTipResponse, Logger, URL, + DenomKeyType, } from "@gnu-taler/taler-util"; import { DerivedTipPlanchet } from "../crypto/cryptoTypes.js"; import { @@ -322,16 +323,20 @@ async function processTipImpl( const planchet = planchets[i]; checkLogicInvariant(!!planchet); - const denomSig = await ws.cryptoApi.rsaUnblind( + if (denom.denomPub.cipher !== 1) { + throw Error("unsupported cipher"); + } + + const denomSigRsa = await ws.cryptoApi.rsaUnblind( blindedSig, planchet.blindingKey, - denom.denomPub, + denom.denomPub.rsa_public_key, ); const isValid = await ws.cryptoApi.rsaVerify( planchet.coinPub, - denomSig, - denom.denomPub, + denomSigRsa, + denom.denomPub.rsa_public_key, ); if (!isValid) { @@ -364,7 +369,7 @@ async function processTipImpl( currentAmount: denom.value, denomPub: denom.denomPub, denomPubHash: denom.denomPubHash, - denomSig: denomSig, + denomSig: { cipher: DenomKeyType.Rsa, rsa_signature: denomSigRsa }, exchangeBaseUrl: tipRecord.exchangeBaseUrl, status: CoinStatus.Fresh, suspended: false, diff --git a/packages/taler-wallet-core/src/operations/withdraw.test.ts b/packages/taler-wallet-core/src/operations/withdraw.test.ts index b4f0d35e6..179852966 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.test.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.test.ts @@ -28,8 +28,11 @@ test("withdrawal selection bug repro", (t) => { const denoms: DenominationRecord[] = [ { - denomPub: - "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000XT67C8KBD6B75TTQ3SK8FWXMNQW4372T3BDDGPAMB9RFCA03638W8T3F71WFEFK9NP32VKYVNFXPYRWQ1N1HDKV5J0DFEKHBPJCYSWCBJDRNWD7G8BN8PT97FA9AMV75MYEK4X54D1HGJ207JSVJBGFCATSPNTEYNHEQF1F220W00TBZR1HNPDQFD56FG0DJQ9KGHM8EC33H6AY9YN9CNX5R3Z4TZ4Q23W47SBHB13H6W74FQJG1F50X38VRSC4SR8RWBAFB7S4K8D2H4NMRFSQT892A3T0BTBW7HM5C0H2CK6FRKG31F7W9WP1S29013K5CXYE55CT8TH6N8J9B780R42Y5S3ZB6J6E9H76XBPSGH4TGYSR2VZRB98J417KCQMZKX1BB67E7W5KVE37TC9SJ904002", + }, denomPubHash: "Q21FQSSG4FXNT96Z14CHXM8N1RZAG9GPHAV8PRWS0PZAAVWH7PBW6R97M2CH19KKP65NNSWXY7B6S53PT3CBM342E357ZXDDJ8RDVW8", exchangeBaseUrl: "https://exchange.demo.taler.net/", @@ -79,8 +82,12 @@ test("withdrawal selection bug repro", (t) => { listIssueDate: { t_ms: 0 }, }, { - denomPub: - "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000Y63CF78QFPKRY77BRK9P557Q1GQWX3NCZ3HSYSK0Z7TT0KGRA7N4SKBKEHSTVHX1Z9DNXMJR4EXSY1TXCKV0GJ3T3YYC6Z0JNMJFVYQAV4FX5J90NZH1N33MZTV8HS9SMNAA9S6K73G4P99GYBB01B0P6M1KXZ5JRDR7VWBR3MEJHHGJ6QBMCJR3NWJRE3WJW9PRY8QPQ2S7KFWTWRESH2DBXCXWBD2SRN6P9YX8GRAEMFEGXC9V5GVJTEMH6ZDGNXFPWZE3JVJ2Q4N9GDYKBCHZCJ7M7M2RJ9ZV4Y64NAN9BT6XDC68215GKKRHTW1BBF1MYY6AR3JCTT9HYAM923RMVQR3TAEB7SDX8J76XRZWYH3AGJCZAQGMN5C8SSH9AHQ9RNQJQ15CN45R37X4YNFJV904002", + }, + denomPubHash: "447WA23SCBATMABHA0793F92MYTBYVPYMMQHCPKMKVY5P7RZRFMQ6VRW0Y8HRA7177GTBT0TBT08R21DZD129AJ995H9G09XBFE55G8", exchangeBaseUrl: "https://exchange.demo.taler.net/", @@ -130,8 +137,11 @@ test("withdrawal selection bug repro", (t) => { listIssueDate: { t_ms: 0 }, }, { - denomPub: - "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000YDESWC2B962DA4WK356SC50MA3N9KV0ZSGY3RC48JCTY258W909C7EEMT5BTC5KZ5T4CERCZ141P9QF87EK2BD1XEEM5GB07MB3H19WE4CQGAS8X84JBWN83PQGQXVMWE5HFA992KMGHC566GT9ZS2QPHZB6X89C4A80Z663PYAAPXP728VHAKATGNNBQ01ZZ2XD1CH9Y38YZBSPJ4K7GB2J76GBCYAVD9ENHDVWXJAXYRPBX4KSS5TXRR3K5NEN9ZV3AJD2V65K7ABRZDF5D5V1FJZZMNJ5XZ4FEREEKEBV9TDFPGJTKDEHEC60K3DN24DAATRESDJ1ZYYSYSRCAT4BT2B62ARGVMJTT5N2R126DRW9TGRWCW0ZAF2N2WET1H4NJEW77X0QT46Z5R3MZ0XPHD04002", + }, denomPubHash: "JS61DTKAFM0BX8Q4XV3ZSKB921SM8QK745Z2AFXTKFMBHHFNBD8TQ5ETJHFNDGBGX22FFN2A2ERNYG1SGSDQWNQHQQ2B14DBVJYJG8R", exchangeBaseUrl: "https://exchange.demo.taler.net/", @@ -181,8 +191,12 @@ test("withdrawal selection bug repro", (t) => { listIssueDate: { t_ms: 0 }, }, { - denomPub: - "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000YG3T1ADB8DVA6BD3EPV6ZHSHTDW35DEN4VH1AE6CSB7P1PSDTNTJG866PHF6QB1CCWYCVRGA0FVBJ9Q0G7KV7AD9010GDYBQH0NNPHW744MTNXVXWBGGGRGQGYK4DTYN1DSWQ1FZNDSZZPB5BEKG2PDJ93NX2JTN06Y8QMS2G734Z9XHC10EENBG2KVB7EJ3CM8PV1T32RC7AY62F3496E8D8KRHJQQTT67DSGMNKK86QXVDTYW677FG27DP20E8XY3M6FQD53NDJ1WWES91401MV1A3VXVPGC76GZVDD62W3WTJ1YMKHTTA3MRXX3VEAAH3XTKDN1ER7X6CZPMYTF8VK735VP2B2TZGTF28TTW4FZS32SBS64APCDF6SZQ427N5538TJC7SRE71YSP5ET8GS904002", + }, + denomPubHash: "8T51NEY81VMPQ180EQ5WR0YH7GMNNT90W55Q0514KZM18AZT71FHJGJHQXGK0WTA7ACN1X2SD0S53XPBQ1A9KH960R48VCVVM6E3TH8", exchangeBaseUrl: "https://exchange.demo.taler.net/", @@ -232,8 +246,11 @@ test("withdrawal selection bug repro", (t) => { listIssueDate: { t_ms: 0 }, }, { - denomPub: - "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000ZC0G60E9QQ5PD81TSDWD9GV5Y6P8Z05NSPA696DP07NGQQVSRQXBA76Q6PRB0YFX295RG4MTQJXAZZ860ET307HSC2X37XAVGQXRVB8Q4F1V7NP5ZEVKTX75DZK1QRAVHEZGQYKSSH6DBCJNQF6V9WNQF3GEYVA4KCBHA7JF772KHXM9642C28Z0AS4XXXV2PABAN5C8CHYD5H7JDFNK3920W5Q69X0BS84XZ4RE2PW6HM1WZ6KGZ3MKWWWCPKQ1FSFABRBWKAB09PF563BEBXKY6M38QETPH5EDWGANHD0SC3QV0WXYVB7BNHNNQ0J5BNV56K563SYHM4E5ND260YRJSYA1GN5YSW2B1J5T1A1EBNYF2DN6JNJKWXWEQ42G5YS17ZSZ5EWDRA9QKV8EGTCNAD04002", + }, denomPubHash: "A41HW0Q2H9PCNMEWW0C0N45QAYVXZ8SBVRRAHE4W6X24SV1TH38ANTWDT80JXEBW9Z8PVPGT9GFV2EYZWJ5JW5W1N34NFNKHQSZ1PFR", exchangeBaseUrl: "https://exchange.demo.taler.net/", @@ -283,8 +300,11 @@ test("withdrawal selection bug repro", (t) => { listIssueDate: { t_ms: 0 }, }, { - denomPub: - "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002", + denomPub: { + cipher: 1, + rsa_public_key: + "040000ZSK2PMVY6E3NBQ52KXMW029M60F4BWYTDS0FZSD0PE53CNZ9H6TM3GQK1WRTEKQ5GRWJ1J9DY6Y42SP47QVT1XD1G0W05SQ5F3F7P5KSWR0FJBJ9NZBXQEVN8Q4JRC94X3JJ3XV3KBYTZ2HTDFV28C3H2SRR0XGNZB4FY85NDZF1G4AEYJJ9QB3C0V8H70YB8RV3FKTNH7XS4K4HFNZHJ5H9VMX5SM9Z2DX37HA5WFH0E2MJBVVF2BWWA5M0HPPSB365RAE2AMD42Q65A96WD80X27SB2ZNQZ8WX0K13FWF85GZ6YNYAJGE1KGN06JDEKE9QD68Z651D7XE8V6664TVVC8M68S7WD0DSXMJQKQ0BNJXNDE29Q7MRX6DA3RW0PZ44B3TKRK0294FPVZTNSTA6XF04002", + }, denomPubHash: "F5NGBX33DTV4595XZZVK0S2MA1VMXFEJQERE5EBP5DS4QQ9EFRANN7YHWC1TKSHT2K6CQWDBRES8D3DWR0KZF5RET40B4AZXZ0RW1ZG", exchangeBaseUrl: "https://exchange.demo.taler.net/", diff --git a/packages/taler-wallet-core/src/operations/withdraw.ts b/packages/taler-wallet-core/src/operations/withdraw.ts index 620ad88be..57bd49d23 100644 --- a/packages/taler-wallet-core/src/operations/withdraw.ts +++ b/packages/taler-wallet-core/src/operations/withdraw.ts @@ -41,6 +41,7 @@ import { URL, WithdrawUriInfoResponse, VersionMatchResult, + DenomKeyType, } from "@gnu-taler/taler-util"; import { CoinRecord, @@ -495,7 +496,7 @@ async function processPlanchetExchangeRequest( ]); if (!denom) { - console.error("db inconsistent: denom for planchet not found"); + logger.error("db inconsistent: denom for planchet not found"); return; } @@ -589,16 +590,26 @@ async function processPlanchetVerifyAndStoreCoin( const { planchet, exchangeBaseUrl } = d; - const denomSig = await ws.cryptoApi.rsaUnblind( - resp.ev_sig, + const planchetDenomPub = planchet.denomPub; + if (planchetDenomPub.cipher !== DenomKeyType.Rsa) { + throw Error("cipher not supported"); + } + + const evSig = resp.ev_sig; + if (evSig.cipher !== DenomKeyType.Rsa) { + throw Error("unsupported cipher"); + } + + const denomSigRsa = await ws.cryptoApi.rsaUnblind( + evSig.blinded_rsa_signature, planchet.blindingKey, - planchet.denomPub, + planchetDenomPub.rsa_public_key, ); const isValid = await ws.cryptoApi.rsaVerify( planchet.coinPub, - denomSig, - planchet.denomPub, + denomSigRsa, + planchetDenomPub.rsa_public_key, ); if (!isValid) { @@ -629,7 +640,10 @@ async function processPlanchetVerifyAndStoreCoin( currentAmount: planchet.coinValue, denomPub: planchet.denomPub, denomPubHash: planchet.denomPubHash, - denomSig, + denomSig: { + cipher: DenomKeyType.Rsa, + rsa_signature: denomSigRsa, + }, coinEvHash: planchet.coinEvHash, exchangeBaseUrl: exchangeBaseUrl, status: CoinStatus.Fresh, @@ -728,7 +742,9 @@ export async function updateWithdrawalDenoms( batchIdx++, current++ ) { const denom = denominations[current]; - if (denom.verificationStatus === DenominationVerificationStatus.Unverified) { + if ( + denom.verificationStatus === DenominationVerificationStatus.Unverified + ) { logger.trace( `Validating denomination (${current + 1}/${ denominations.length @@ -745,7 +761,8 @@ export async function updateWithdrawalDenoms( ); denom.verificationStatus = DenominationVerificationStatus.VerifiedBad; } else { - denom.verificationStatus = DenominationVerificationStatus.VerifiedGood; + denom.verificationStatus = + DenominationVerificationStatus.VerifiedGood; } updatedDenominations.push(denom); } diff --git a/packages/taler-wallet-core/src/util/coinSelection.test.ts b/packages/taler-wallet-core/src/util/coinSelection.test.ts index ed48b8dd1..b4dc2a18b 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.test.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.test.ts @@ -33,7 +33,10 @@ function fakeAci(current: string, feeDeposit: string): AvailableCoinInfo { return { availableAmount: a(current), coinPub: "foobar", - denomPub: "foobar", + denomPub: { + cipher: 1, + rsa_public_key: "foobar", + }, feeDeposit: a(feeDeposit), exchangeBaseUrl: "https://example.com/", }; diff --git a/packages/taler-wallet-core/src/util/coinSelection.ts b/packages/taler-wallet-core/src/util/coinSelection.ts index 500cee5d8..ba26c98fe 100644 --- a/packages/taler-wallet-core/src/util/coinSelection.ts +++ b/packages/taler-wallet-core/src/util/coinSelection.ts @@ -23,7 +23,7 @@ /** * Imports. */ -import { AmountJson, Amounts } from "@gnu-taler/taler-util"; +import { AmountJson, Amounts, DenominationPubKey } from "@gnu-taler/taler-util"; import { strcmp, Logger } from "@gnu-taler/taler-util"; const logger = new Logger("coinSelection.ts"); @@ -72,7 +72,7 @@ export interface AvailableCoinInfo { /** * Coin's denomination public key. */ - denomPub: string; + denomPub: DenominationPubKey; /** * Amount still remaining (typically the full amount, @@ -206,6 +206,21 @@ function tallyFees( }; } +function denomPubCmp( + p1: DenominationPubKey, + p2: DenominationPubKey, +): -1 | 0 | 1 { + if (p1.cipher < p2.cipher) { + return -1; + } else if (p1.cipher > p2.cipher) { + return +1; + } + if (p1.cipher !== 1 || p2.cipher !== 1) { + throw Error("unsupported cipher"); + } + return strcmp(p1.rsa_public_key, p2.rsa_public_key); +} + /** * Given a list of candidate coins, select coins to spend under the merchant's * constraints. @@ -272,7 +287,7 @@ export function selectPayCoins( (o1, o2) => -Amounts.cmp(o1.availableAmount, o2.availableAmount) || Amounts.cmp(o1.feeDeposit, o2.feeDeposit) || - strcmp(o1.denomPub, o2.denomPub), + denomPubCmp(o1.denomPub, o2.denomPub), ); // FIXME: Here, we should select coins in a smarter way. diff --git a/packages/taler-wallet-core/src/wallet.ts b/packages/taler-wallet-core/src/wallet.ts index 32e3945e8..cd2dd7f1e 100644 --- a/packages/taler-wallet-core/src/wallet.ts +++ b/packages/taler-wallet-core/src/wallet.ts @@ -387,6 +387,7 @@ async function runTaskLoop( } catch (e) { if (e instanceof OperationFailedAndReportedError) { logger.warn("operation processed resulted in reported error"); + logger.warn(`reporred error was: ${j2s(e.operationError)}`); } else { logger.error("Uncaught exception", e); ws.notify({ @@ -929,7 +930,7 @@ async function dispatchRequestInternal( } const components = pt.targetPath.split("/"); const creditorAcct = components[components.length - 1]; - logger.info(`making testbank transfer to '${creditorAcct}''`) + logger.info(`making testbank transfer to '${creditorAcct}''`); const fbReq = await ws.http.postJson( new URL(`${creditorAcct}/admin/add-incoming`, req.bank).href, { diff --git a/packages/taler-wallet-webextension/package.json b/packages/taler-wallet-webextension/package.json index 3a43f1e76..b3d0b10af 100644 --- a/packages/taler-wallet-webextension/package.json +++ b/packages/taler-wallet-webextension/package.json @@ -13,6 +13,7 @@ "compile": "tsc && rollup -c", "build-storybook": "build-storybook", "storybook": "start-storybook -s . -p 6006", + "pretty": "prettier --write src", "watch": "tsc --watch & rollup -w -c" }, "dependencies": { @@ -80,4 +81,4 @@ "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|po)$": "<rootDir>/tests/__mocks__/fileTransformer.js" } } -} +}
\ No newline at end of file diff --git a/packages/taler-wallet-webextension/src/NavigationBar.tsx b/packages/taler-wallet-webextension/src/NavigationBar.tsx index 9edd8ca67..56704fb57 100644 --- a/packages/taler-wallet-webextension/src/NavigationBar.tsx +++ b/packages/taler-wallet-webextension/src/NavigationBar.tsx @@ -25,32 +25,32 @@ * Imports. */ import { i18n } from "@gnu-taler/taler-util"; -import { ComponentChildren, JSX, h } from "preact"; +import { ComponentChildren, h, VNode } from "preact"; import Match from "preact-router/match"; +import { PopupNavigation } from "./components/styled"; import { useDevContext } from "./context/devContext"; -import { PopupNavigation } from './components/styled' export enum Pages { - welcome = '/welcome', - balance = '/balance', - manual_withdraw = '/manual-withdraw', - settings = '/settings', - dev = '/dev', - cta = '/cta', - backup = '/backup', - history = '/history', - transaction = '/transaction/:tid', - provider_detail = '/provider/:pid', - provider_add = '/provider/add', + welcome = "/welcome", + balance = "/balance", + manual_withdraw = "/manual-withdraw", + settings = "/settings", + dev = "/dev", + cta = "/cta", + backup = "/backup", + history = "/history", + transaction = "/transaction/:tid", + provider_detail = "/provider/:pid", + provider_add = "/provider/add", - reset_required = '/reset-required', - payback = '/payback', - return_coins = '/return-coins', + reset_required = "/reset-required", + payback = "/payback", + return_coins = "/return-coins", - pay = '/pay', - refund = '/refund', - tips = '/tip', - withdraw = '/withdraw', + pay = "/pay", + refund = "/refund", + tips = "/tip", + withdraw = "/withdraw", } interface TabProps { @@ -59,7 +59,7 @@ interface TabProps { children?: ComponentChildren; } -function Tab(props: TabProps): JSX.Element { +function Tab(props: TabProps): VNode { let cssClass = ""; if (props.current?.startsWith(props.target)) { cssClass = "active"; @@ -71,23 +71,28 @@ function Tab(props: TabProps): JSX.Element { ); } -export function NavBar({ devMode, path }: { path: string, devMode: boolean }) { - return <PopupNavigation devMode={devMode}> - <div> - <Tab target="/balance" current={path}>{i18n.str`Balance`}</Tab> - <Tab target="/history" current={path}>{i18n.str`History`}</Tab> - <Tab target="/backup" current={path}>{i18n.str`Backup`}</Tab> - <Tab target="/settings" current={path}>{i18n.str`Settings`}</Tab> - {devMode && <Tab target="/dev" current={path}>{i18n.str`Dev`}</Tab>} - </div> - </PopupNavigation> +export function NavBar({ devMode, path }: { path: string; devMode: boolean }) { + return ( + <PopupNavigation devMode={devMode}> + <div> + <Tab target="/balance" current={path}>{i18n.str`Balance`}</Tab> + <Tab target="/history" current={path}>{i18n.str`History`}</Tab> + <Tab target="/backup" current={path}>{i18n.str`Backup`}</Tab> + <Tab target="/settings" current={path}>{i18n.str`Settings`}</Tab> + {devMode && <Tab target="/dev" current={path}>{i18n.str`Dev`}</Tab>} + </div> + </PopupNavigation> + ); } export function WalletNavBar() { - const { devMode } = useDevContext() - return <Match>{({ path }: any) => { - console.log("path", path) - return <NavBar devMode={devMode} path={path} /> - }}</Match> + const { devMode } = useDevContext(); + return ( + <Match> + {({ path }: any) => { + console.log("path", path); + return <NavBar devMode={devMode} path={path} />; + }} + </Match> + ); } - diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js index e9492a2fb..8d958d6bd 100644 --- a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js +++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.js @@ -21,24 +21,23 @@ exports.BrowserCryptoWorkerFactory = void 0; * @author Florian Dold */ class BrowserCryptoWorkerFactory { - startWorker() { - const workerCtor = Worker; - const workerPath = "/browserWorkerEntry.js"; - return new workerCtor(workerPath); - } - getConcurrency() { - let concurrency = 2; - try { - // only works in the browser - // tslint:disable-next-line:no-string-literal - concurrency = navigator["hardwareConcurrency"]; - concurrency = Math.max(1, Math.ceil(concurrency / 2)); - } - catch (e) { - concurrency = 2; - } - return concurrency; + startWorker() { + const workerCtor = Worker; + const workerPath = "/browserWorkerEntry.js"; + return new workerCtor(workerPath); + } + getConcurrency() { + let concurrency = 2; + try { + // only works in the browser + // tslint:disable-next-line:no-string-literal + concurrency = navigator["hardwareConcurrency"]; + concurrency = Math.max(1, Math.ceil(concurrency / 2)); + } catch (e) { + concurrency = 2; } + return concurrency; + } } exports.BrowserCryptoWorkerFactory = BrowserCryptoWorkerFactory; -//# sourceMappingURL=browserCryptoWorkerFactory.js.map
\ No newline at end of file +//# sourceMappingURL=browserCryptoWorkerFactory.js.map diff --git a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts index a8315dc6d..ab20228ef 100644 --- a/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts +++ b/packages/taler-wallet-webextension/src/browserCryptoWorkerFactory.ts @@ -19,7 +19,10 @@ * @author Florian Dold */ -import type { CryptoWorker, CryptoWorkerFactory } from "@gnu-taler/taler-wallet-core"; +import type { + CryptoWorker, + CryptoWorkerFactory, +} from "@gnu-taler/taler-wallet-core"; export class BrowserCryptoWorkerFactory implements CryptoWorkerFactory { startWorker(): CryptoWorker { diff --git a/packages/taler-wallet-webextension/src/browserWorkerEntry.ts b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts index b5c26a7bb..7829e6d65 100644 --- a/packages/taler-wallet-webextension/src/browserWorkerEntry.ts +++ b/packages/taler-wallet-webextension/src/browserWorkerEntry.ts @@ -42,7 +42,7 @@ async function handleRequest( } try { - const result = (impl as any)[operation](...args); + const result = await (impl as any)[operation](...args); worker.postMessage({ result, id }); } catch (e) { logger.error("error during operation", e); diff --git a/packages/taler-wallet-webextension/src/compat.js b/packages/taler-wallet-webextension/src/compat.js index fdfcbd4b9..48e49a0a7 100644 --- a/packages/taler-wallet-webextension/src/compat.js +++ b/packages/taler-wallet-webextension/src/compat.js @@ -21,41 +21,44 @@ exports.getPermissionsApi = exports.isNode = exports.isFirefox = void 0; * WebExtension APIs consistently. */ function isFirefox() { - const rt = chrome.runtime; - if (typeof rt.getBrowserInfo === "function") { - return true; - } - return false; + const rt = chrome.runtime; + if (typeof rt.getBrowserInfo === "function") { + return true; + } + return false; } exports.isFirefox = isFirefox; /** * Check if we are running under nodejs. */ function isNode() { - return typeof process !== "undefined" && process.release.name === "node"; + return typeof process !== "undefined" && process.release.name === "node"; } exports.isNode = isNode; function getPermissionsApi() { - const myBrowser = globalThis.browser; - if (typeof myBrowser === "object" && - typeof myBrowser.permissions === "object") { - return { - addPermissionsListener: () => { - // Not supported yet. - }, - contains: myBrowser.permissions.contains, - request: myBrowser.permissions.request, - remove: myBrowser.permissions.remove, - }; - } - else { - return { - addPermissionsListener: chrome.permissions.onAdded.addListener.bind(chrome.permissions.onAdded), - contains: chrome.permissions.contains, - request: chrome.permissions.request, - remove: chrome.permissions.remove, - }; - } + const myBrowser = globalThis.browser; + if ( + typeof myBrowser === "object" && + typeof myBrowser.permissions === "object" + ) { + return { + addPermissionsListener: () => { + // Not supported yet. + }, + contains: myBrowser.permissions.contains, + request: myBrowser.permissions.request, + remove: myBrowser.permissions.remove, + }; + } else { + return { + addPermissionsListener: chrome.permissions.onAdded.addListener.bind( + chrome.permissions.onAdded, + ), + contains: chrome.permissions.contains, + request: chrome.permissions.request, + remove: chrome.permissions.remove, + }; + } } exports.getPermissionsApi = getPermissionsApi; -//# sourceMappingURL=compat.js.map
\ No newline at end of file +//# sourceMappingURL=compat.js.map diff --git a/packages/taler-wallet-webextension/src/components/Checkbox.tsx b/packages/taler-wallet-webextension/src/components/Checkbox.tsx index 2d7b98087..59e84f4b0 100644 --- a/packages/taler-wallet-webextension/src/components/Checkbox.tsx +++ b/packages/taler-wallet-webextension/src/components/Checkbox.tsx @@ -14,8 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX } from "preact/jsx-runtime"; -import { h } from "preact"; +import { h, VNode } from "preact"; interface Props { enabled: boolean; @@ -24,7 +23,13 @@ interface Props { name: string; description?: string; } -export function Checkbox({ name, enabled, onToggle, label, description }: Props): JSX.Element { +export function Checkbox({ + name, + enabled, + onToggle, + label, + description, +}: Props): VNode { return ( <div> <input @@ -32,23 +37,26 @@ export function Checkbox({ name, enabled, onToggle, label, description }: Props) onClick={onToggle} type="checkbox" id={`checkbox-${name}`} - style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} /> + style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} + /> <label htmlFor={`checkbox-${name}`} style={{ marginLeft: "0.5em", fontWeight: "bold" }} > {label} </label> - {description && <span - style={{ - color: "#383838", - fontSize: "smaller", - display: "block", - marginLeft: "2em", - }} - > - {description} - </span>} + {description && ( + <span + style={{ + color: "#383838", + fontSize: "smaller", + display: "block", + marginLeft: "2em", + }} + > + {description} + </span> + )} </div> ); } diff --git a/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx b/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx index 5e30ee3d1..3b9519f39 100644 --- a/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx +++ b/packages/taler-wallet-webextension/src/components/CheckboxOutlined.tsx @@ -14,9 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX } from "preact/jsx-runtime"; import { Outlined, StyledCheckboxLabel } from "./styled/index"; -import { h } from 'preact'; +import { h, VNode } from "preact"; interface Props { enabled: boolean; @@ -25,28 +24,39 @@ interface Props { name: string; } +const Tick = () => ( + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 24 24" + aria-hidden="true" + focusable="false" + style={{ backgroundColor: "green" }} + > + <path + fill="none" + stroke="white" + stroke-width="3" + d="M1.73 12.91l6.37 6.37L22.79 4.59" + /> + </svg> +); -const Tick = () => <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 24 24" - aria-hidden="true" - focusable="false" - style={{ backgroundColor: 'green' }} -> - <path - fill="none" - stroke="white" - stroke-width="3" - d="M1.73 12.91l6.37 6.37L22.79 4.59" - /> -</svg> - -export function CheckboxOutlined({ name, enabled, onToggle, label }: Props): JSX.Element { +export function CheckboxOutlined({ + name, + enabled, + onToggle, + label, +}: Props): VNode { return ( <Outlined> <StyledCheckboxLabel onClick={onToggle}> <span> - <input type="checkbox" name={name} checked={enabled} disabled={false} /> + <input + type="checkbox" + name={name} + checked={enabled} + disabled={false} + /> <div> <Tick /> </div> diff --git a/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx b/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx index f0c682ccb..b57075805 100644 --- a/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx +++ b/packages/taler-wallet-webextension/src/components/DebugCheckbox.tsx @@ -14,9 +14,15 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { JSX, h } from "preact"; +import { h, VNode } from "preact"; -export function DebugCheckbox({ enabled, onToggle }: { enabled: boolean; onToggle: () => void; }): JSX.Element { +export function DebugCheckbox({ + enabled, + onToggle, +}: { + enabled: boolean; + onToggle: () => void; +}): VNode { return ( <div> <input @@ -24,7 +30,8 @@ export function DebugCheckbox({ enabled, onToggle }: { enabled: boolean; onToggl onClick={onToggle} type="checkbox" id="checkbox-perm" - style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} /> + style={{ width: "1.5em", height: "1.5em", verticalAlign: "middle" }} + /> <label htmlFor="checkbox-perm" style={{ marginLeft: "0.5em", fontWeight: "bold" }} diff --git a/packages/taler-wallet-webextension/src/components/Diagnostics.tsx b/packages/taler-wallet-webextension/src/components/Diagnostics.tsx index b48deb847..d368a10bf 100644 --- a/packages/taler-wallet-webextension/src/components/Diagnostics.tsx +++ b/packages/taler-wallet-webextension/src/components/Diagnostics.tsx @@ -15,58 +15,55 @@ */ import { WalletDiagnostics } from "@gnu-taler/taler-util"; -import { h } from "preact"; -import { JSX } from "preact/jsx-runtime"; +import { Fragment, h, VNode } from "preact"; import { PageLink } from "../renderHtml"; interface Props { timedOut: boolean; - diagnostics: WalletDiagnostics | undefined + diagnostics: WalletDiagnostics | undefined; } -export function Diagnostics({timedOut, diagnostics}: Props): JSX.Element | null { - +export function Diagnostics({ timedOut, diagnostics }: Props): VNode { if (timedOut) { return <p>Diagnostics timed out. Could not talk to the wallet backend.</p>; } if (diagnostics) { if (diagnostics.errors.length === 0) { - return null; - } else { - return ( - <div - style={{ - borderLeft: "0.5em solid red", - paddingLeft: "1em", - paddingTop: "0.2em", - paddingBottom: "0.2em", - }} - > - <p>Problems detected:</p> - <ol> - {diagnostics.errors.map((errMsg) => ( - <li key={errMsg}>{errMsg}</li> - ))} - </ol> - {diagnostics.firefoxIdbProblem ? ( - <p> - Please check in your <code>about:config</code> settings that you - have IndexedDB enabled (check the preference name{" "} - <code>dom.indexedDB.enabled</code>). - </p> - ) : null} - {diagnostics.dbOutdated ? ( - <p> - Your wallet database is outdated. Currently automatic migration is - not supported. Please go{" "} - <PageLink pageName="/reset-required">here</PageLink> to reset - the wallet database. - </p> - ) : null} - </div> - ); + return <Fragment />; } + return ( + <div + style={{ + borderLeft: "0.5em solid red", + paddingLeft: "1em", + paddingTop: "0.2em", + paddingBottom: "0.2em", + }} + > + <p>Problems detected:</p> + <ol> + {diagnostics.errors.map((errMsg) => ( + <li key={errMsg}>{errMsg}</li> + ))} + </ol> + {diagnostics.firefoxIdbProblem ? ( + <p> + Please check in your <code>about:config</code> settings that you + have IndexedDB enabled (check the preference name{" "} + <code>dom.indexedDB.enabled</code>). + </p> + ) : null} + {diagnostics.dbOutdated ? ( + <p> + Your wallet database is outdated. Currently automatic migration is + not supported. Please go{" "} + <PageLink pageName="/reset-required">here</PageLink> to reset the + wallet database. + </p> + ) : null} + </div> + ); } return <p>Running diagnostics ...</p>; diff --git a/packages/taler-wallet-webextension/src/components/EditableText.tsx b/packages/taler-wallet-webextension/src/components/EditableText.tsx index 6f3388bf9..72bfbe809 100644 --- a/packages/taler-wallet-webextension/src/components/EditableText.tsx +++ b/packages/taler-wallet-webextension/src/components/EditableText.tsx @@ -14,9 +14,8 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { h } from "preact"; +import { h, VNode } from "preact"; import { useRef, useState } from "preact/hooks"; -import { JSX } from "preact/jsx-runtime"; interface Props { value: string; @@ -25,25 +24,41 @@ interface Props { name: string; description?: string; } -export function EditableText({ name, value, onChange, label, description }: Props): JSX.Element { - const [editing, setEditing] = useState(false) - const ref = useRef<HTMLInputElement>(null) +export function EditableText({ + name, + value, + onChange, + label, + description, +}: Props): VNode { + const [editing, setEditing] = useState(false); + const ref = useRef<HTMLInputElement>(null); let InputText; if (!editing) { - InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}> - <p>{value}</p> - <button onClick={() => setEditing(true)}>edit</button> - </div> + InputText = function InputToEdit(): VNode { + return ( + <div style={{ display: "flex", justifyContent: "space-between" }}> + <p>{value}</p> + <button onClick={() => setEditing(true)}>edit</button> + </div> + ); + }; } else { - InputText = () => <div style={{ display: 'flex', justifyContent: 'space-between' }}> - <input - value={value} - ref={ref} - type="text" - id={`text-${name}`} - /> - <button onClick={() => { if (ref.current) onChange(ref.current.value).then(r => setEditing(false)) }}>confirm</button> - </div> + InputText = function InputEditing(): VNode { + return ( + <div style={{ display: "flex", justifyContent: "space-between" }}> + <input value={value} ref={ref} type="text" id={`text-${name}`} /> + <button + onClick={() => { + if (ref.current) + onChange(ref.current.value).then(() => setEditing(false)); + }} + > + confirm + </button> + </div> + ); + }; } return ( <div> @@ -54,16 +69,18 @@ export function EditableText({ name, value, onChange, label, description }: Prop {label} </label> <InputText /> - {description && <span - style={{ - color: "#383838", - fontSize: "smaller", - display: "block", - marginLeft: "2em", - }} - > - {description} - </span>} + {description && ( + <span + style={{ + color: "#383838", + fontSize: "smaller", + display: "block", + marginLeft: "2em", + }} + > + {description} + </span> + )} </div> ); } diff --git a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx index cfcef16d5..c6b64fb6a 100644 --- a/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx +++ b/packages/taler-wallet-webextension/src/components/ErrorMessage.tsx @@ -13,22 +13,35 @@ 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 { VNode, h } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; -import arrowDown from '../../static/img/chevron-down.svg'; +import arrowDown from "../../static/img/chevron-down.svg"; import { ErrorBox } from "./styled"; -export function ErrorMessage({ title, description }: { title?: string|VNode; description?: string; }) { +export function ErrorMessage({ + title, + description, +}: { + title?: string | VNode; + description?: string; +}) { const [showErrorDetail, setShowErrorDetail] = useState(false); - if (!title) - return null; - return <ErrorBox style={{paddingTop: 0, paddingBottom: 0}}> - <div> - <p>{title}</p> - { description && <button onClick={() => { setShowErrorDetail(v => !v); }}> - <img style={{ height: '1.5em' }} src={arrowDown} /> - </button> } - </div> - {showErrorDetail && <p>{description}</p>} - </ErrorBox>; + if (!title) return null; + return ( + <ErrorBox style={{ paddingTop: 0, paddingBottom: 0 }}> + <div> + <p>{title}</p> + {description && ( + <button + onClick={() => { + setShowErrorDetail((v) => !v); + }} + > + <img style={{ height: "1.5em" }} src={arrowDown} /> + </button> + )} + </div> + {showErrorDetail && <p>{description}</p>} + </ErrorBox> + ); } diff --git a/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx b/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx index cfa20280f..a71108c50 100644 --- a/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx +++ b/packages/taler-wallet-webextension/src/components/ExchangeToS.tsx @@ -13,66 +13,78 @@ 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 { Fragment, VNode } from "preact" -import { useState } from "preact/hooks" -import { JSXInternal } from "preact/src/jsx" -import { h } from 'preact'; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; -export function ExchangeXmlTos({ doc }: { doc: Document }) { - const termsNode = doc.querySelector('[ids=terms-of-service]') +export function ExchangeXmlTos({ doc }: { doc: Document }): VNode { + const termsNode = doc.querySelector("[ids=terms-of-service]"); if (!termsNode) { - return <div> - <p>The exchange send us an xml but there is no node with 'ids=terms-of-service'. This is the content:</p> - <pre>{new XMLSerializer().serializeToString(doc)}</pre> - </div> + return ( + <div> + <p> + The exchange send us an xml but there is no node with + 'ids=terms-of-service'. This is the content: + </p> + <pre>{new XMLSerializer().serializeToString(doc)}</pre> + </div> + ); } - return <Fragment> - {Array.from(termsNode.children).map(renderChild)} - </Fragment> + return <Fragment>{Array.from(termsNode.children).map(renderChild)}</Fragment>; } /** * Map XML elements into HTML - * @param child - * @returns + * @param child + * @returns */ function renderChild(child: Element): VNode { - const children = Array.from(child.children) + const children = Array.from(child.children); switch (child.nodeName) { - case 'title': return <header>{child.textContent}</header> - case '#text': return <Fragment /> - case 'paragraph': return <p>{child.textContent}</p> - case 'section': { - return <AnchorWithOpenState href={`#terms-${child.getAttribute('ids')}`}> - {children.map(renderChild)} - </AnchorWithOpenState> + case "title": + return <header>{child.textContent}</header>; + case "#text": + return <Fragment />; + case "paragraph": + return <p>{child.textContent}</p>; + case "section": { + return ( + <AnchorWithOpenState href={`#terms-${child.getAttribute("ids")}`}> + {children.map(renderChild)} + </AnchorWithOpenState> + ); } - case 'bullet_list': { - return <ul>{children.map(renderChild)}</ul> + case "bullet_list": { + return <ul>{children.map(renderChild)}</ul>; } - case 'enumerated_list': { - return <ol>{children.map(renderChild)}</ol> + case "enumerated_list": { + return <ol>{children.map(renderChild)}</ol>; } - case 'list_item': { - return <li>{children.map(renderChild)}</li> + case "list_item": { + return <li>{children.map(renderChild)}</li>; } - case 'block_quote': { - return <div>{children.map(renderChild)}</div> + case "block_quote": { + return <div>{children.map(renderChild)}</div>; } - default: return <div style={{ color: 'red', display: 'hidden' }}>unknown tag {child.nodeName} <a></a></div> + default: + return ( + <div style={{ color: "red", display: "hidden" }}> + unknown tag {child.nodeName} + </div> + ); } } /** * Simple anchor with a state persisted into 'data-open' prop - * @returns + * @returns */ -function AnchorWithOpenState(props: JSXInternal.HTMLAttributes<HTMLAnchorElement>) { - const [open, setOpen] = useState<boolean>(false) - function doClick(e: JSXInternal.TargetedMouseEvent<HTMLAnchorElement>) { +function AnchorWithOpenState( + props: h.JSX.HTMLAttributes<HTMLAnchorElement>, +): VNode { + const [open, setOpen] = useState<boolean>(false); + function doClick(e: h.JSX.TargetedMouseEvent<HTMLAnchorElement>): void { setOpen(!open); e.preventDefault(); } - return <a data-open={open ? 'true' : 'false'} onClick={doClick} {...props} /> + return <a data-open={open ? "true" : "false"} onClick={doClick} {...props} />; } - diff --git a/packages/taler-wallet-webextension/src/components/LogoHeader.tsx b/packages/taler-wallet-webextension/src/components/LogoHeader.tsx index 9b75c62a1..6c47dc92a 100644 --- a/packages/taler-wallet-webextension/src/components/LogoHeader.tsx +++ b/packages/taler-wallet-webextension/src/components/LogoHeader.tsx @@ -17,15 +17,22 @@ import { h } from "preact"; export function LogoHeader() { - return <div style={{ - display: 'flex', - justifyContent: 'space-around', - margin: '2em', - }}> - <img style={{ - width: 150, - height: 70, - }} src="/static/img/logo-2021.svg" width="150" /> - </div> - -}
\ No newline at end of file + return ( + <div + style={{ + display: "flex", + justifyContent: "space-around", + margin: "2em", + }} + > + <img + style={{ + width: 150, + height: 70, + }} + src="/static/img/logo-2021.svg" + width="150" + /> + </div> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/Part.tsx b/packages/taler-wallet-webextension/src/components/Part.tsx index 75c9df16f..c8ecb46d2 100644 --- a/packages/taler-wallet-webextension/src/components/Part.tsx +++ b/packages/taler-wallet-webextension/src/components/Part.tsx @@ -15,18 +15,28 @@ */ import { AmountLike } from "@gnu-taler/taler-util"; import { ExtraLargeText, LargeText, SmallLightText } from "./styled"; -import { h } from 'preact'; +import { h } from "preact"; -export type Kind = 'positive' | 'negative' | 'neutral'; +export type Kind = "positive" | "negative" | "neutral"; interface Props { - title: string, text: AmountLike, kind: Kind, big?: boolean + title: string; + text: AmountLike; + kind: Kind; + big?: boolean; } export function Part({ text, title, kind, big }: Props) { const Text = big ? ExtraLargeText : LargeText; - return <div style={{ margin: '1em' }}> - <SmallLightText style={{ margin: '.5em' }}>{title}</SmallLightText> - <Text style={{ color: kind == 'positive' ? 'green' : (kind == 'negative' ? 'red' : 'black') }}> - {text} - </Text> - </div> + return ( + <div style={{ margin: "1em" }}> + <SmallLightText style={{ margin: ".5em" }}>{title}</SmallLightText> + <Text + style={{ + color: + kind == "positive" ? "green" : kind == "negative" ? "red" : "black", + }} + > + {text} + </Text> + </div> + ); } diff --git a/packages/taler-wallet-webextension/src/components/QR.tsx b/packages/taler-wallet-webextension/src/components/QR.tsx index 8e3f69295..4ff1af961 100644 --- a/packages/taler-wallet-webextension/src/components/QR.tsx +++ b/packages/taler-wallet-webextension/src/components/QR.tsx @@ -14,24 +14,35 @@ 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(() => { - if (!divRef.current) return - const qr = qrcode(0, 'L'); - qr.addData(text); - qr.make(); - 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>; - } -
\ No newline at end of file +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(() => { + if (!divRef.current) return; + const qr = qrcode(0, "L"); + qr.addData(text); + qr.make(); + divRef.current.innerHTML = qr.createSvgTag({ + scalable: true, + }); + }); + + return ( + <div + style={{ + width: "100%", + display: "flex", + flexDirection: "column", + alignItems: "center", + }} + > + <div + style={{ width: "50%", minWidth: 200, maxWidth: 300 }} + ref={divRef} + /> + </div> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/SelectList.tsx b/packages/taler-wallet-webextension/src/components/SelectList.tsx index 536e5b89a..78dd2feb4 100644 --- a/packages/taler-wallet-webextension/src/components/SelectList.tsx +++ b/packages/taler-wallet-webextension/src/components/SelectList.tsx @@ -14,55 +14,74 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX } from "preact/jsx-runtime"; +import { Fragment, h, VNode } from "preact"; import { NiceSelect } from "./styled/index"; -import { h } from "preact"; interface Props { value?: string; onChange: (s: string) => void; label: string; list: { - [label: string]: string - } + [label: string]: string; + }; name: string; description?: string; canBeNull?: boolean; } -export function SelectList({ name, value, list, canBeNull, onChange, label, description }: Props): JSX.Element { - return <div> - <label - htmlFor={`text-${name}`} - style={{ marginLeft: "0.5em", fontWeight: "bold" }} - > {label}</label> - <NiceSelect> - <select name={name} onChange={(e) => { - console.log(e.currentTarget.value, value) - onChange(e.currentTarget.value) - }}> - {value !== undefined ? <option selected> - {list[value]} - </option> : <option selected disabled> - Select one option - </option>} - {Object.keys(list) - .filter((l) => l !== value) - .map(key => <option value={key} key={key}>{list[key]}</option>) - } - </select> - </NiceSelect> - {description && <span - style={{ - color: "#383838", - fontSize: "smaller", - display: "block", - marginLeft: "2em", - }} - > - {description} - </span>} - - </div> - +export function SelectList({ + name, + value, + list, + onChange, + label, + description, +}: Props): VNode { + return ( + <Fragment> + <label + htmlFor={`text-${name}`} + style={{ marginLeft: "0.5em", fontWeight: "bold" }} + > + {" "} + {label} + </label> + <NiceSelect> + <select + name={name} + onChange={(e) => { + console.log(e.currentTarget.value, value); + onChange(e.currentTarget.value); + }} + > + {value !== undefined ? ( + <option selected>{list[value]}</option> + ) : ( + <option selected disabled> + Select one option + </option> + )} + {Object.keys(list) + .filter((l) => l !== value) + .map((key) => ( + <option value={key} key={key}> + {list[key]} + </option> + ))} + </select> + </NiceSelect> + {description && ( + <span + style={{ + color: "#383838", + fontSize: "smaller", + display: "block", + marginLeft: "2em", + }} + > + {description} + </span> + )} + </Fragment> + ); } diff --git a/packages/taler-wallet-webextension/src/components/Time.tsx b/packages/taler-wallet-webextension/src/components/Time.tsx new file mode 100644 index 000000000..452b08334 --- /dev/null +++ b/packages/taler-wallet-webextension/src/components/Time.tsx @@ -0,0 +1,41 @@ +/* + This file is part of TALER + (C) 2016 GNUnet e.V. + + 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. + + 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 + TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { Timestamp } from "@gnu-taler/taler-util"; +import { formatISO, format } from "date-fns"; +import { h, VNode } from "preact"; + +export function Time({ + timestamp, + format: formatString, +}: { + timestamp: Timestamp | undefined; + format: string; +}): VNode { + return ( + <time + dateTime={ + !timestamp || timestamp.t_ms === "never" + ? undefined + : formatISO(timestamp.t_ms) + } + > + {!timestamp || timestamp.t_ms === "never" + ? "never" + : format(timestamp.t_ms, formatString)} + </time> + ); +} diff --git a/packages/taler-wallet-webextension/src/components/TransactionItem.tsx b/packages/taler-wallet-webextension/src/components/TransactionItem.tsx index 991e97c94..99ca86385 100644 --- a/packages/taler-wallet-webextension/src/components/TransactionItem.tsx +++ b/packages/taler-wallet-webextension/src/components/TransactionItem.tsx @@ -14,18 +14,33 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountString, Timestamp, Transaction, TransactionType } from '@gnu-taler/taler-util'; -import { format, formatDistance } from 'date-fns'; -import { h } from 'preact'; -import imageBank from '../../static/img/ri-bank-line.svg'; -import imageHandHeart from '../../static/img/ri-hand-heart-line.svg'; -import imageRefresh from '../../static/img/ri-refresh-line.svg'; -import imageRefund from '../../static/img/ri-refund-2-line.svg'; -import imageShoppingCart from '../../static/img/ri-shopping-cart-line.svg'; +import { + AmountString, + Timestamp, + Transaction, + TransactionType, +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import imageBank from "../../static/img/ri-bank-line.svg"; +import imageHandHeart from "../../static/img/ri-hand-heart-line.svg"; +import imageRefresh from "../../static/img/ri-refresh-line.svg"; +import imageRefund from "../../static/img/ri-refund-2-line.svg"; +import imageShoppingCart from "../../static/img/ri-shopping-cart-line.svg"; import { Pages } from "../NavigationBar"; -import { Column, ExtraLargeText, HistoryRow, SmallLightText, LargeText, LightText } from './styled/index'; +import { + Column, + ExtraLargeText, + HistoryRow, + SmallLightText, + LargeText, + LightText, +} from "./styled/index"; +import { Time } from "./Time"; -export function TransactionItem(props: { tx: Transaction, multiCurrency: boolean }): JSX.Element { +export function TransactionItem(props: { + tx: Transaction; + multiCurrency: boolean; +}): VNode { const tx = props.tx; switch (tx.type) { case TransactionType.Withdrawal: @@ -110,22 +125,27 @@ export function TransactionItem(props: { tx: Transaction, multiCurrency: boolean } } -function TransactionLayout(props: TransactionLayoutProps): JSX.Element { - const date = new Date(props.timestamp.t_ms); - const dateStr = format(date, 'dd MMM, hh:mm') - +function TransactionLayout(props: TransactionLayoutProps): VNode { return ( - <HistoryRow href={Pages.transaction.replace(':tid', props.id)}> + <HistoryRow href={Pages.transaction.replace(":tid", props.id)}> <img src={props.iconPath} /> <Column> <LargeText> <div>{props.title}</div> - {props.subtitle && <div style={{color:'gray', fontSize:'medium', marginTop: 5}}>{props.subtitle}</div>} + {props.subtitle && ( + <div style={{ color: "gray", fontSize: "medium", marginTop: 5 }}> + {props.subtitle} + </div> + )} </LargeText> - {props.pending && - <LightText style={{ marginTop: 5, marginBottom: 5 }}>Waiting for confirmation</LightText> - } - <SmallLightText style={{marginTop:5 }}>{dateStr}</SmallLightText> + {props.pending && ( + <LightText style={{ marginTop: 5, marginBottom: 5 }}> + Waiting for confirmation + </LightText> + )} + <SmallLightText style={{ marginTop: 5 }}> + <Time timestamp={props.timestamp} format="dd MMM, hh:mm" /> + </SmallLightText> </Column> <TransactionAmount pending={props.pending} @@ -156,7 +176,7 @@ interface TransactionAmountProps { multiCurrency: boolean; } -function TransactionAmount(props: TransactionAmountProps): JSX.Element { +function TransactionAmount(props: TransactionAmountProps): VNode { const [currency, amount] = props.amount.split(":"); let sign: string; switch (props.debitCreditIndicator) { @@ -170,14 +190,18 @@ function TransactionAmount(props: TransactionAmountProps): JSX.Element { sign = ""; } return ( - <Column style={{ - textAlign: 'center', - color: - props.pending ? "gray" : - (sign === '+' ? 'darkgreen' : - (sign === '-' ? 'darkred' : - undefined)) - }}> + <Column + style={{ + textAlign: "center", + color: props.pending + ? "gray" + : sign === "+" + ? "darkgreen" + : sign === "-" + ? "darkred" + : undefined, + }} + > <ExtraLargeText> {sign} {amount} @@ -187,4 +211,3 @@ function TransactionAmount(props: TransactionAmountProps): JSX.Element { </Column> ); } - diff --git a/packages/taler-wallet-webextension/src/components/styled/index.tsx b/packages/taler-wallet-webextension/src/components/styled/index.tsx index 65c1f49e9..2db7c61f8 100644 --- a/packages/taler-wallet-webextension/src/components/styled/index.tsx +++ b/packages/taler-wallet-webextension/src/components/styled/index.tsx @@ -14,18 +14,17 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - // need to import linaria types, otherwise compiler will complain -import type * as Linaria from '@linaria/core'; +import type * as Linaria from "@linaria/core"; -import { styled } from '@linaria/react'; +import { styled } from "@linaria/react"; export const PaymentStatus = styled.div<{ color: string }>` padding: 5px; border-radius: 5px; color: white; - background-color: ${p => p.color}; -` + background-color: ${(p) => p.color}; +`; export const WalletAction = styled.div` display: flex; @@ -36,9 +35,9 @@ export const WalletAction = styled.div` margin: auto; height: 100%; - + & h1:first-child { - margin-top: 0; + margin-top: 0; } section { margin-bottom: 2em; @@ -47,7 +46,7 @@ export const WalletAction = styled.div` margin-left: 8px; } } -` +`; export const WalletActionOld = styled.section` border: solid 5px black; border-radius: 10px; @@ -59,17 +58,17 @@ export const WalletActionOld = styled.section` margin: auto; height: 100%; - + & h1:first-child { - margin-top: 0; + margin-top: 0; } -` +`; export const DateSeparator = styled.div` color: gray; - margin: .2em; + margin: 0.2em; margin-top: 1em; -` +`; export const WalletBox = styled.div<{ noPadding?: boolean }>` display: flex; flex-direction: column; @@ -79,14 +78,14 @@ export const WalletBox = styled.div<{ noPadding?: boolean }>` width: 400px; } & > section { - padding-left: ${({ noPadding }) => noPadding ? '0px' : '8px'}; - padding-right: ${({ noPadding }) => noPadding ? '0px' : '8px'}; + padding-left: ${({ noPadding }) => (noPadding ? "0px" : "8px")}; + padding-right: ${({ noPadding }) => (noPadding ? "0px" : "8px")}; // this margin will send the section up when used with a header - margin-bottom: auto; + margin-bottom: auto; overflow: auto; table td { - padding: 5px 10px; + padding: 5px 5px; } table tr { border-bottom: 1px solid black; @@ -128,13 +127,13 @@ export const WalletBox = styled.div<{ noPadding?: boolean }>` margin-left: 8px; } } -` +`; export const Middle = styled.div` - justify-content: space-around; - display: flex; - flex-direction: column; - height: 100%; -` + justify-content: space-around; + display: flex; + flex-direction: column; + height: 100%; +`; export const PopupBox = styled.div<{ noPadding?: boolean }>` height: 290px; @@ -144,9 +143,9 @@ export const PopupBox = styled.div<{ noPadding?: boolean }>` justify-content: space-between; & > section { - padding: ${({ noPadding }) => noPadding ? '0px' : '8px'}; + padding: ${({ noPadding }) => (noPadding ? "0px" : "8px")}; // this margin will send the section up when used with a header - margin-bottom: auto; + margin-bottom: auto; overflow-y: auto; table td { @@ -201,8 +200,7 @@ export const PopupBox = styled.div<{ noPadding?: boolean }>` margin-left: 8px; } } - -` +`; export const Button = styled.button<{ upperCased?: boolean }>` display: inline-block; @@ -214,7 +212,7 @@ export const Button = styled.button<{ upperCased?: boolean }>` cursor: pointer; user-select: none; box-sizing: border-box; - text-transform: ${({ upperCased }) => upperCased ? 'uppercase' : 'none'}; + text-transform: ${({ upperCased }) => (upperCased ? "uppercase" : "none")}; font-family: inherit; font-size: 100%; @@ -223,7 +221,7 @@ export const Button = styled.button<{ upperCased?: boolean }>` color: rgba(0, 0, 0, 0.8); /* rgba supported */ border: 1px solid #999; /*IE 6/7/8*/ border: none rgba(0, 0, 0, 0); /*IE9 + everything else*/ - background-color: '#e6e6e6'; + background-color: "#e6e6e6"; text-decoration: none; border-radius: 2px; @@ -263,7 +261,7 @@ export const Link = styled.a<{ upperCased?: boolean }>` cursor: pointer; user-select: none; box-sizing: border-box; - text-transform: ${({ upperCased }) => upperCased ? 'uppercase' : 'none'}; + text-transform: ${({ upperCased }) => (upperCased ? "uppercase" : "none")}; font-family: inherit; font-size: 100%; @@ -304,9 +302,9 @@ export const FontIcon = styled.div` text-align: center; font-weight: bold; /* vertical-align: text-top; */ -` +`; export const ButtonBox = styled(Button)` - padding: .5em; + padding: 0.5em; width: fit-content; height: 2em; @@ -322,89 +320,88 @@ export const ButtonBox = styled(Button)` border-radius: 4px; border-color: black; color: black; -` - +`; const ButtonVariant = styled(Button)` color: white; border-radius: 4px; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.2); -` +`; -export const ButtonPrimary = styled(ButtonVariant)` +export const ButtonPrimary = styled(ButtonVariant)<{ small?: boolean }>` + font-size: ${({ small }) => (small ? "small" : "inherit")}; background-color: rgb(66, 184, 221); -` +`; export const ButtonBoxPrimary = styled(ButtonBox)` color: rgb(66, 184, 221); border-color: rgb(66, 184, 221); -` +`; export const ButtonSuccess = styled(ButtonVariant)` background-color: #388e3c; -` +`; export const LinkSuccess = styled(Link)` color: #388e3c; -` +`; export const ButtonBoxSuccess = styled(ButtonBox)` color: #388e3c; border-color: #388e3c; -` +`; export const ButtonWarning = styled(ButtonVariant)` background-color: rgb(223, 117, 20); -` +`; export const LinkWarning = styled(Link)` color: rgb(223, 117, 20); -` +`; export const ButtonBoxWarning = styled(ButtonBox)` color: rgb(223, 117, 20); border-color: rgb(223, 117, 20); -` +`; export const ButtonDestructive = styled(ButtonVariant)` background-color: rgb(202, 60, 60); -` +`; export const ButtonBoxDestructive = styled(ButtonBox)` color: rgb(202, 60, 60); border-color: rgb(202, 60, 60); -` - +`; export const BoldLight = styled.div` -color: gray; -font-weight: bold; -` + color: gray; + font-weight: bold; +`; export const Centered = styled.div` text-align: center; & > :not(:first-child) { margin-top: 15px; } -` +`; export const Row = styled.div` display: flex; margin: 0.5em 0; justify-content: space-between; padding: 0.5em; -` +`; export const Row2 = styled.div` display: flex; /* margin: 0.5em 0; */ justify-content: space-between; padding: 0.5em; -` +`; export const Column = styled.div` display: flex; flex-direction: column; margin: 0em 1em; justify-content: space-between; -` +`; export const RowBorderGray = styled(Row)` border: 1px solid gray; /* border-radius: 0.5em; */ -` +`; export const RowLightBorderGray = styled(Row2)` border: 1px solid lightgray; @@ -414,7 +411,7 @@ export const RowLightBorderGray = styled(Row2)` border: 1px solid lightgray; background-color: red; } -` +`; export const HistoryRow = styled.a` text-decoration: none; @@ -423,7 +420,7 @@ export const HistoryRow = styled.a` display: flex; justify-content: space-between; padding: 0.5em; - + border: 1px solid lightgray; border-top: 0px; @@ -439,7 +436,7 @@ export const HistoryRow = styled.a` margin-left: auto; align-self: center; } -` +`; export const ListOfProducts = styled.div` & > div > a > img { @@ -453,83 +450,94 @@ export const ListOfProducts = styled.div` margin-right: auto; margin-left: 1em; } -` +`; export const LightText = styled.div` color: gray; -` +`; export const WarningText = styled.div` color: rgb(223, 117, 20); -` +`; export const SmallText = styled.div` - font-size: small; -` + font-size: small; +`; export const LargeText = styled.div` - font-size: large; -` + font-size: large; +`; export const ExtraLargeText = styled.div` - font-size: x-large; -` + font-size: x-large; +`; export const SmallLightText = styled(SmallText)` color: gray; -` +`; export const CenteredText = styled.div` white-space: nowrap; text-align: center; -` +`; export const CenteredBoldText = styled(CenteredText)` white-space: nowrap; text-align: center; font-weight: bold; color: ${((props: any): any => String(props.color) as any) as any}; -` +`; export const Input = styled.div<{ invalid?: boolean }>` & label { display: block; padding: 5px; - color: ${({ invalid }) => !invalid ? 'inherit' : 'red'} + color: ${({ invalid }) => (!invalid ? "inherit" : "red")}; } & input { display: block; padding: 5px; width: calc(100% - 4px - 10px); - border-color: ${({ invalid }) => !invalid ? 'inherit' : 'red'} + border-color: ${({ invalid }) => (!invalid ? "inherit" : "red")}; } -` +`; export const InputWithLabel = styled.div<{ invalid?: boolean }>` + /* display: flex; */ + & label { display: block; + font-weight: bold; + margin-left: 0.5em; padding: 5px; - color: ${({ invalid }) => !invalid ? 'inherit' : 'red'} + color: ${({ invalid }) => (!invalid ? "inherit" : "red")}; } - & > div { - position: relative; - display: flex; - top: 0px; - bottom: 0px; - - & > div { - position: absolute; - background-color: lightgray; - padding: 5px; - margin: 2px; - } - & > input { - flex: 1; - padding: 5px; - border-color: ${({ invalid }) => !invalid ? 'inherit' : 'red'} - } + & div { + line-height: 24px; + display: flex; } -` + & div > span { + background-color: lightgray; + box-sizing: border-box; + border-bottom-left-radius: 0.25em; + border-top-left-radius: 0.25em; + height: 2em; + display: inline-block; + padding-left: 0.5em; + padding-right: 0.5em; + align-items: center; + display: flex; + } + & input { + border-width: 1px; + box-sizing: border-box; + height: 2em; + /* border-color: lightgray; */ + border-bottom-right-radius: 0.25em; + border-top-right-radius: 0.25em; + border-color: ${({ invalid }) => (!invalid ? "lightgray" : "red")}; + } +`; export const ErrorBox = styled.div` border: 2px solid #f5c6cb; @@ -539,6 +547,7 @@ export const ErrorBox = styled.div` flex-direction: column; /* margin: 0.5em; */ padding: 1em; + margin: 1em; /* width: 100%; */ color: #721c24; background: #f8d7da; @@ -555,22 +564,22 @@ export const ErrorBox = styled.div` width: 28px; } } -` +`; export const SuccessBox = styled(ErrorBox)` color: #0f5132; background-color: #d1e7dd; border-color: #badbcc; -` +`; export const WarningBox = styled(ErrorBox)` color: #664d03; background-color: #fff3cd; border-color: #ffecb5; -` +`; export const PopupNavigation = styled.div<{ devMode?: boolean }>` - background-color:#0042b2; + background-color: #0042b2; height: 35px; justify-content: space-around; display: flex; @@ -582,7 +591,7 @@ export const PopupNavigation = styled.div<{ devMode?: boolean }>` & > div > a { color: #f8faf7; display: inline-block; - width: calc(400px / ${({ devMode }) => !devMode ? 4 : 5}); + width: calc(400px / ${({ devMode }) => (!devMode ? 4 : 5)}); text-align: center; text-decoration: none; vertical-align: middle; @@ -596,8 +605,9 @@ export const PopupNavigation = styled.div<{ devMode?: boolean }>` } `; -export const NiceSelect = styled.div` +const image = `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`; +export const NiceSelect = styled.div` & > select { -webkit-appearance: none; -moz-appearance: none; @@ -605,11 +615,18 @@ export const NiceSelect = styled.div` appearance: none; outline: 0; box-shadow: none; - background-image: none; + + background-image: ${image}; + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + background-color: white; - flex: 1; - padding: 0.5em 1em; + border-radius: 0.25rem; + font-size: 1em; + padding: 0.5em 3em 0.5em 1em; cursor: pointer; } @@ -617,29 +634,8 @@ export const NiceSelect = styled.div` display: flex; /* width: 10em; */ overflow: hidden; - border-radius: .25em; - - &::after { - content: '\u25BC'; - position: absolute; - top: 0; - right: 0; - padding: 0.5em 1em; - cursor: pointer; - pointer-events: none; - -webkit-transition: .25s all ease; - -o-transition: .25s all ease; - transition: .25s all ease; - } - - &:hover::after { - /* color: #f39c12; */ - } - - &::-ms-expand { - display: none; - } -` + border-radius: 0.25em; +`; export const Outlined = styled.div` border: 2px solid #388e3c; @@ -647,13 +643,12 @@ export const Outlined = styled.div` width: fit-content; border-radius: 2px; color: #388e3c; -` +`; /* { width: "1.5em", height: "1.5em", verticalAlign: "middle" } */ export const CheckboxSuccess = styled.input` vertical-align: center; - -` +`; export const TermsSection = styled.a` border: 1px solid black; @@ -664,13 +659,13 @@ export const TermsSection = styled.a` text-decoration: none; color: inherit; flex-direction: column; - + display: flex; &[data-open="true"] { - display: flex; + display: flex; } &[data-open="false"] > *:not(:first-child) { - display: none; + display: none; } header { @@ -681,11 +676,11 @@ export const TermsSection = styled.a` height: auto; } - &[data-open="true"] header:after { - content: '\\2227'; + &[data-open="true"] header:after { + content: "\\2227"; } - &[data-open="false"] header:after { - content: '\\2228'; + &[data-open="false"] header:after { + content: "\\2228"; } `; @@ -712,13 +707,13 @@ export const TermsOfService = styled.div` padding: 1em; margin-top: 2px; margin-bottom: 2px; - + display: flex; &[data-open="true"] { - display: flex; + display: flex; } &[data-open="false"] > *:not(:first-child) { - display: none; + display: none; } header { @@ -729,22 +724,20 @@ export const TermsOfService = styled.div` height: auto; } - &[data-open="true"] > header:after { - content: '\\2227'; + &[data-open="true"] > header:after { + content: "\\2227"; } - &[data-open="false"] > header:after { - content: '\\2228'; + &[data-open="false"] > header:after { + content: "\\2228"; } } - -` +`; export const StyledCheckboxLabel = styled.div` color: green; text-transform: uppercase; /* font-weight: bold; */ text-align: center; span { - input { display: none; opacity: 0; @@ -758,7 +751,7 @@ export const StyledCheckboxLabel = styled.div` margin-right: 1em; border-radius: 2px; border: 2px solid currentColor; - + svg { transition: transform 0.1s ease-in 25ms; transform: scale(0); @@ -776,12 +769,11 @@ export const StyledCheckboxLabel = styled.div` } input:disabled + div { color: #959495; - }; + } input:disabled + div + label { color: #959495; - }; + } input:focus + div + label { box-shadow: 0 0 0 0.05em #fff, 0 0 0.15em 0.1em currentColor; } - -`
\ No newline at end of file +`; diff --git a/packages/taler-wallet-webextension/src/context/devContext.ts b/packages/taler-wallet-webextension/src/context/devContext.ts index ea2ba4ceb..0344df057 100644 --- a/packages/taler-wallet-webextension/src/context/devContext.ts +++ b/packages/taler-wallet-webextension/src/context/devContext.ts @@ -15,13 +15,13 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact' -import { useContext, useState } from 'preact/hooks' -import { useLocalStorage } from '../hooks/useLocalStorage'; +import { createContext, h, VNode } from "preact"; +import { useContext, useState } from "preact/hooks"; +import { useLocalStorage } from "../hooks/useLocalStorage"; interface Type { devMode: boolean; @@ -29,14 +29,14 @@ interface Type { } const Context = createContext<Type>({ devMode: false, - toggleDevMode: () => null -}) + toggleDevMode: () => null, +}); export const useDevContext = (): Type => useContext(Context); export const DevContextProvider = ({ children }: { children: any }): VNode => { - const [value, setter] = useLocalStorage('devMode') - const devMode = value === "true" - const toggleDevMode = () => setter(v => !v ? "true" : undefined) + const [value, setter] = useLocalStorage("devMode"); + const devMode = value === "true"; + const toggleDevMode = () => setter((v) => (!v ? "true" : undefined)); return h(Context.Provider, { value: { devMode, toggleDevMode }, children }); -} +}; diff --git a/packages/taler-wallet-webextension/src/context/translation.ts b/packages/taler-wallet-webextension/src/context/translation.ts index 5f57958de..105da9dcf 100644 --- a/packages/taler-wallet-webextension/src/context/translation.ts +++ b/packages/taler-wallet-webextension/src/context/translation.ts @@ -15,54 +15,58 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createContext, h, VNode } from 'preact' -import { useContext, useEffect } from 'preact/hooks' -import { useLang } from '../hooks/useLang' +import { createContext, h, VNode } from "preact"; +import { useContext, useEffect } from "preact/hooks"; +import { useLang } from "../hooks/useLang"; //@ts-ignore: type declaration import * as jedLib from "jed"; import { strings } from "../i18n/strings"; -import { setupI18n } from '@gnu-taler/taler-util'; +import { setupI18n } from "@gnu-taler/taler-util"; interface Type { lang: string; changeLanguage: (l: string) => void; } const initial = { - lang: 'en', + lang: "en", changeLanguage: () => { // do not change anything - } -} -const Context = createContext<Type>(initial) + }, +}; +const Context = createContext<Type>(initial); interface Props { - initial?: string, - children: any, - forceLang?: string + initial?: string; + children: any; + forceLang?: string; } -//we use forceLang when we don't want to use the saved state, but sone forced -//runtime lang predefined lang -export const TranslationProvider = ({ initial, children, forceLang }: Props): VNode => { - const [lang, changeLanguage] = useLang(initial) +//we use forceLang when we don't want to use the saved state, but sone forced +//runtime lang predefined lang +export const TranslationProvider = ({ + initial, + children, + forceLang, +}: Props): VNode => { + const [lang, changeLanguage] = useLang(initial); useEffect(() => { if (forceLang) { - changeLanguage(forceLang) + changeLanguage(forceLang); } - }) - useEffect(()=> { - setupI18n(lang, strings) - },[lang]) + }); + useEffect(() => { + setupI18n(lang, strings); + }, [lang]); if (forceLang) { - setupI18n(forceLang, strings) + setupI18n(forceLang, strings); } else { - setupI18n(lang, strings) + setupI18n(lang, strings); } return h(Context.Provider, { value: { lang, changeLanguage }, children }); -} +}; export const useTranslationContext = (): Type => useContext(Context); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx index 622e7950f..c2d360d3b 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Pay.stories.tsx @@ -15,150 +15,156 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { ContractTerms, PreparePayResultType } from '@gnu-taler/taler-util'; -import { createExample } from '../test-utils'; -import { PaymentRequestView as TestedComponent } from './Pay'; +import { ContractTerms, PreparePayResultType } from "@gnu-taler/taler-util"; +import { createExample } from "../test-utils"; +import { PaymentRequestView as TestedComponent } from "./Pay"; export default { - title: 'cta/pay', + title: "cta/pay", component: TestedComponent, - argTypes: { - }, + argTypes: {}, }; export const NoBalance = createExample(TestedComponent, { payStatus: { status: PreparePayResultType.InsufficientBalance, - noncePriv: '', + noncePriv: "", proposalId: "proposal1234", - contractTerms: { + contractTerms: ({ merchant: { - name: 'someone' + name: "someone", }, - summary: 'some beers', - amount: 'USD:10', - } as Partial<ContractTerms> as any, - amountRaw: 'USD:10', - } + summary: "some beers", + amount: "USD:10", + } as Partial<ContractTerms>) as any, + amountRaw: "USD:10", + }, }); export const NoEnoughBalance = createExample(TestedComponent, { payStatus: { status: PreparePayResultType.InsufficientBalance, - noncePriv: '', + noncePriv: "", proposalId: "proposal1234", - contractTerms: { + contractTerms: ({ merchant: { - name: 'someone' + name: "someone", }, - summary: 'some beers', - amount: 'USD:10', - } as Partial<ContractTerms> as any, - amountRaw: 'USD:10', + summary: "some beers", + amount: "USD:10", + } as Partial<ContractTerms>) as any, + amountRaw: "USD:10", }, balance: { - currency: 'USD', + currency: "USD", fraction: 40000000, - value: 9 - } + value: 9, + }, }); export const PaymentPossible = createExample(TestedComponent, { - uri: 'taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0', + uri: + "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", payStatus: { status: PreparePayResultType.PaymentPossible, - amountEffective: 'USD:10', - amountRaw: 'USD:10', - noncePriv: '', - contractTerms: { - nonce: '123213123', + amountEffective: "USD:10", + amountRaw: "USD:10", + noncePriv: "", + contractTerms: ({ + nonce: "123213123", merchant: { - name: 'someone' + name: "someone", }, - amount: 'USD:10', - summary: 'some beers', - } as Partial<ContractTerms> as any, - contractTermsHash: '123456', - proposalId: 'proposal1234' - } + amount: "USD:10", + summary: "some beers", + } as Partial<ContractTerms>) as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + }, }); export const PaymentPossibleWithFee = createExample(TestedComponent, { - uri: 'taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0', + uri: + "taler://pay/merchant-backend.taler/2021.242-01G2X4275RBWG/?c=66BE594PDZR24744J6EQK52XM0", payStatus: { status: PreparePayResultType.PaymentPossible, - amountEffective: 'USD:10.20', - amountRaw: 'USD:10', - noncePriv: '', - contractTerms: { - nonce: '123213123', + amountEffective: "USD:10.20", + amountRaw: "USD:10", + noncePriv: "", + contractTerms: ({ + nonce: "123213123", merchant: { - name: 'someone' + name: "someone", }, - amount: 'USD:10', - summary: 'some beers', - } as Partial<ContractTerms> as any, - contractTermsHash: '123456', - proposalId: 'proposal1234' - } + amount: "USD:10", + summary: "some beers", + } as Partial<ContractTerms>) as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + }, }); export const AlreadyConfirmedWithFullfilment = createExample(TestedComponent, { payStatus: { status: PreparePayResultType.AlreadyConfirmed, - amountEffective: 'USD:10', - amountRaw: 'USD:10', - contractTerms: { + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: ({ merchant: { - name: 'someone' + name: "someone", }, - fulfillment_message: 'congratulations! you are looking at the fulfillment message! ', - summary: 'some beers', - amount: 'USD:10', - } as Partial<ContractTerms> as any, - contractTermsHash: '123456', - proposalId: 'proposal1234', + fulfillment_message: + "congratulations! you are looking at the fulfillment message! ", + summary: "some beers", + amount: "USD:10", + } as Partial<ContractTerms>) as any, + contractTermsHash: "123456", + proposalId: "proposal1234", paid: false, - } + }, }); -export const AlreadyConfirmedWithoutFullfilment = createExample(TestedComponent, { - payStatus: { - status: PreparePayResultType.AlreadyConfirmed, - amountEffective: 'USD:10', - amountRaw: 'USD:10', - contractTerms: { - merchant: { - name: 'someone' - }, - summary: 'some beers', - amount: 'USD:10', - } as Partial<ContractTerms> as any, - contractTermsHash: '123456', - proposalId: 'proposal1234', - paid: false, - } -}); +export const AlreadyConfirmedWithoutFullfilment = createExample( + TestedComponent, + { + payStatus: { + status: PreparePayResultType.AlreadyConfirmed, + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: ({ + merchant: { + name: "someone", + }, + summary: "some beers", + amount: "USD:10", + } as Partial<ContractTerms>) as any, + contractTermsHash: "123456", + proposalId: "proposal1234", + paid: false, + }, + }, +); export const AlreadyPaid = createExample(TestedComponent, { payStatus: { status: PreparePayResultType.AlreadyConfirmed, - amountEffective: 'USD:10', - amountRaw: 'USD:10', - contractTerms: { + amountEffective: "USD:10", + amountRaw: "USD:10", + contractTerms: ({ merchant: { - name: 'someone' + name: "someone", }, - fulfillment_message: 'congratulations! you are looking at the fulfillment message! ', - summary: 'some beers', - amount: 'USD:10', - } as Partial<ContractTerms> as any, - contractTermsHash: '123456', - proposalId: 'proposal1234', + fulfillment_message: + "congratulations! you are looking at the fulfillment message! ", + summary: "some beers", + amount: "USD:10", + } as Partial<ContractTerms>) as any, + contractTermsHash: "123456", + proposalId: "proposal1234", paid: true, - } + }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Pay.tsx b/packages/taler-wallet-webextension/src/cta/Pay.tsx index 675b14ff9..d5861c47c 100644 --- a/packages/taler-wallet-webextension/src/cta/Pay.tsx +++ b/packages/taler-wallet-webextension/src/cta/Pay.tsx @@ -24,18 +24,36 @@ */ // import * as i18n from "../i18n"; -import { AmountJson, AmountLike, Amounts, ConfirmPayResult, ConfirmPayResultDone, ConfirmPayResultType, ContractTerms, getJsonI18n, i18n, PreparePayResult, PreparePayResultType } from "@gnu-taler/taler-util"; -import { Fragment, JSX, VNode } from "preact"; +import { + AmountJson, + AmountLike, + Amounts, + ConfirmPayResult, + ConfirmPayResultDone, + ConfirmPayResultType, + ContractTerms, + i18n, + PreparePayResult, + PreparePayResultType, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { LogoHeader } from "../components/LogoHeader"; import { Part } from "../components/Part"; import { QR } from "../components/QR"; -import { ButtonSuccess, ErrorBox, LinkSuccess, SuccessBox, WalletAction, WarningBox } from "../components/styled"; +import { + ButtonSuccess, + ErrorBox, + LinkSuccess, + SuccessBox, + WalletAction, + WarningBox, +} from "../components/styled"; import { useBalances } from "../hooks/useBalances"; import * as wxApi from "../wxApi"; interface Props { - talerPayUri?: string + talerPayUri?: string; } // export function AlreadyPaid({ payStatus }: { payStatus: PreparePayResult }) { @@ -64,7 +82,9 @@ interface Props { // </section> // } -const doPayment = async (payStatus: PreparePayResult): Promise<ConfirmPayResultDone> => { +const doPayment = async ( + payStatus: PreparePayResult, +): Promise<ConfirmPayResultDone> => { if (payStatus.status !== "payment-possible") { throw Error(`invalid state: ${payStatus.status}`); } @@ -80,18 +100,29 @@ const doPayment = async (payStatus: PreparePayResult): Promise<ConfirmPayResultD return res; }; - - -export function PayPage({ talerPayUri }: Props): JSX.Element { - const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>(undefined); - const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>(undefined); +export function PayPage({ talerPayUri }: Props): VNode { + const [payStatus, setPayStatus] = useState<PreparePayResult | undefined>( + undefined, + ); + const [payResult, setPayResult] = useState<ConfirmPayResult | undefined>( + undefined, + ); const [payErrMsg, setPayErrMsg] = useState<string | undefined>(undefined); - const balance = useBalances() - const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || []) + const balance = useBalances(); + const balanceWithoutError = balance?.hasError + ? [] + : balance?.response.balances || []; - const foundBalance = balanceWithoutError.find(b => payStatus && Amounts.parseOrThrow(b.available).currency === Amounts.parseOrThrow(payStatus?.amountRaw).currency) - const foundAmount = foundBalance ? Amounts.parseOrThrow(foundBalance.available) : undefined + const foundBalance = balanceWithoutError.find( + (b) => + payStatus && + Amounts.parseOrThrow(b.available).currency === + Amounts.parseOrThrow(payStatus?.amountRaw).currency, + ); + const foundAmount = foundBalance + ? Amounts.parseOrThrow(foundBalance.available) + : undefined; useEffect(() => { if (!talerPayUri) return; @@ -101,7 +132,7 @@ export function PayPage({ talerPayUri }: Props): JSX.Element { setPayStatus(p); } catch (e) { if (e instanceof Error) { - setPayErrMsg(e.message) + setPayErrMsg(e.message); } } }; @@ -109,30 +140,28 @@ export function PayPage({ talerPayUri }: Props): JSX.Element { }, [talerPayUri]); if (!talerPayUri) { - return <span>missing pay uri</span> + return <span>missing pay uri</span>; } if (!payStatus) { if (payErrMsg) { - return <WalletAction> - <LogoHeader /> - <h2> - {i18n.str`Digital cash payment`} - </h2> - <section> - <p>Could not get the payment information for this order</p> - <ErrorBox> - {payErrMsg} - </ErrorBox> - </section> - </WalletAction> + return ( + <WalletAction> + <LogoHeader /> + <h2>{i18n.str`Digital cash payment`}</h2> + <section> + <p>Could not get the payment information for this order</p> + <ErrorBox>{payErrMsg}</ErrorBox> + </section> + </WalletAction> + ); } return <span>Loading payment information ...</span>; } - const onClick = async () => { + const onClick = async (): Promise<void> => { try { - const res = await doPayment(payStatus) + const res = await doPayment(payStatus); setPayResult(res); } catch (e) { console.error(e); @@ -140,13 +169,18 @@ export function PayPage({ talerPayUri }: Props): JSX.Element { setPayErrMsg(e.message); } } + }; - } - - return <PaymentRequestView uri={talerPayUri} - payStatus={payStatus} payResult={payResult} - onClick={onClick} payErrMsg={payErrMsg} - balance={foundAmount} />; + return ( + <PaymentRequestView + uri={talerPayUri} + payStatus={payStatus} + payResult={payResult} + onClick={onClick} + payErrMsg={payErrMsg} + balance={foundAmount} + /> + ); } export interface PaymentRequestViewProps { @@ -157,7 +191,14 @@ export interface PaymentRequestViewProps { uri: string; balance: AmountJson | undefined; } -export function PaymentRequestView({ uri, payStatus, payResult, onClick, payErrMsg, balance }: PaymentRequestViewProps) { +export function PaymentRequestView({ + uri, + payStatus, + payResult, + onClick, + payErrMsg, + balance, +}: PaymentRequestViewProps): VNode { let totalFees: AmountJson = Amounts.getZero(payStatus.amountRaw); const contractTerms: ContractTerms = payStatus.contractTerms; @@ -184,117 +225,175 @@ export function PaymentRequestView({ uri, payStatus, payResult, onClick, payErrM merchantName = <strong>(pub: {contractTerms.merchant_pub})</strong>; } - function Alternative() { - const [showQR, setShowQR] = useState<boolean>(false) - const privateUri = payStatus.status !== PreparePayResultType.AlreadyConfirmed ? `${uri}&n=${payStatus.noncePriv}` : uri - return <section> - <LinkSuccess upperCased onClick={() => setShowQR(qr => !qr)}> - {!showQR ? i18n.str`Pay with a mobile phone` : i18n.str`Hide QR`} - </LinkSuccess> - {showQR && <div> - <QR text={privateUri} /> - Scan the QR code or <a href={privateUri}>click here</a> - </div>} - </section> + function Alternative(): VNode { + const [showQR, setShowQR] = useState<boolean>(false); + const privateUri = + payStatus.status !== PreparePayResultType.AlreadyConfirmed + ? `${uri}&n=${payStatus.noncePriv}` + : uri; + return ( + <section> + <LinkSuccess upperCased onClick={() => setShowQR((qr) => !qr)}> + {!showQR ? i18n.str`Pay with a mobile phone` : i18n.str`Hide QR`} + </LinkSuccess> + {showQR && ( + <div> + <QR text={privateUri} /> + Scan the QR code or <a href={privateUri}>click here</a> + </div> + )} + </section> + ); } - function ButtonsSection() { + function ButtonsSection(): VNode { if (payResult) { if (payResult.type === ConfirmPayResultType.Pending) { - return <section> - <div> - <p>Processing...</p> - </div> - </section> + return ( + <section> + <div> + <p>Processing...</p> + </div> + </section> + ); } - return null + return <Fragment />; } if (payErrMsg) { - return <section> - <div> - <p>Payment failed: {payErrMsg}</p> - <button class="pure-button button-success" onClick={onClick} > - {i18n.str`Retry`} - </button> - </div> - </section> - } - if (payStatus.status === PreparePayResultType.PaymentPossible) { - return <Fragment> + return ( <section> - <ButtonSuccess upperCased onClick={onClick}> - {i18n.str`Pay`} {amountToString(payStatus.amountEffective)} - </ButtonSuccess> + <div> + <p>Payment failed: {payErrMsg}</p> + <button class="pure-button button-success" onClick={onClick}> + {i18n.str`Retry`} + </button> + </div> </section> - <Alternative /> - </Fragment> + ); + } + if (payStatus.status === PreparePayResultType.PaymentPossible) { + return ( + <Fragment> + <section> + <ButtonSuccess upperCased onClick={onClick}> + {i18n.str`Pay`} {amountToString(payStatus.amountEffective)} + </ButtonSuccess> + </section> + <Alternative /> + </Fragment> + ); } if (payStatus.status === PreparePayResultType.InsufficientBalance) { - return <Fragment> - <section> - {balance ? <WarningBox> - Your balance of {amountToString(balance)} is not enough to pay for this purchase - </WarningBox> : <WarningBox> - Your balance is not enough to pay for this purchase. - </WarningBox>} - </section> - <section> - <ButtonSuccess upperCased> - {i18n.str`Withdraw digital cash`} - </ButtonSuccess> - </section> - <Alternative /> - </Fragment> + return ( + <Fragment> + <section> + {balance ? ( + <WarningBox> + Your balance of {amountToString(balance)} is not enough to pay + for this purchase + </WarningBox> + ) : ( + <WarningBox> + Your balance is not enough to pay for this purchase. + </WarningBox> + )} + </section> + <section> + <ButtonSuccess upperCased> + {i18n.str`Withdraw digital cash`} + </ButtonSuccess> + </section> + <Alternative /> + </Fragment> + ); } if (payStatus.status === PreparePayResultType.AlreadyConfirmed) { - return <Fragment> - <section> - {payStatus.paid && contractTerms.fulfillment_message && <Part title="Merchant message" text={contractTerms.fulfillment_message} kind='neutral' />} - </section> - {!payStatus.paid && <Alternative />} - </Fragment> + return ( + <Fragment> + <section> + {payStatus.paid && contractTerms.fulfillment_message && ( + <Part + title="Merchant message" + text={contractTerms.fulfillment_message} + kind="neutral" + /> + )} + </section> + {!payStatus.paid && <Alternative />} + </Fragment> + ); } - return <span /> + return <span />; } - return <WalletAction> - <LogoHeader /> - - <h2> - {i18n.str`Digital cash payment`} - </h2> - {payStatus.status === PreparePayResultType.AlreadyConfirmed && - (payStatus.paid ? <SuccessBox> Already paid </SuccessBox> : <WarningBox> Already claimed </WarningBox>) - } - {payResult && payResult.type === ConfirmPayResultType.Done && ( - <SuccessBox> - <h3>Payment complete</h3> - <p>{!payResult.contractTerms.fulfillment_message ? - "You will now be sent back to the merchant you came from." : - payResult.contractTerms.fulfillment_message - }</p> - </SuccessBox> - )} - <section> - {payStatus.status !== PreparePayResultType.InsufficientBalance && Amounts.isNonZero(totalFees) && - <Part big title="Total to pay" text={amountToString(payStatus.amountEffective)} kind='negative' /> - } - <Part big title="Purchase amount" text={amountToString(payStatus.amountRaw)} kind='neutral' /> - {Amounts.isNonZero(totalFees) && <Fragment> - <Part big title="Fee" text={amountToString(totalFees)} kind='negative' /> - </Fragment> - } - <Part title="Merchant" text={contractTerms.merchant.name} kind='neutral' /> - <Part title="Purchase" text={contractTerms.summary} kind='neutral' /> - {contractTerms.order_id && <Part title="Receipt" text={`#${contractTerms.order_id}`} kind='neutral' />} - </section> - <ButtonsSection /> + return ( + <WalletAction> + <LogoHeader /> - </WalletAction> + <h2>{i18n.str`Digital cash payment`}</h2> + {payStatus.status === PreparePayResultType.AlreadyConfirmed && + (payStatus.paid ? ( + <SuccessBox> Already paid </SuccessBox> + ) : ( + <WarningBox> Already claimed </WarningBox> + ))} + {payResult && payResult.type === ConfirmPayResultType.Done && ( + <SuccessBox> + <h3>Payment complete</h3> + <p> + {!payResult.contractTerms.fulfillment_message + ? "You will now be sent back to the merchant you came from." + : payResult.contractTerms.fulfillment_message} + </p> + </SuccessBox> + )} + <section> + {payStatus.status !== PreparePayResultType.InsufficientBalance && + Amounts.isNonZero(totalFees) && ( + <Part + big + title="Total to pay" + text={amountToString(payStatus.amountEffective)} + kind="negative" + /> + )} + <Part + big + title="Purchase amount" + text={amountToString(payStatus.amountRaw)} + kind="neutral" + /> + {Amounts.isNonZero(totalFees) && ( + <Fragment> + <Part + big + title="Fee" + text={amountToString(totalFees)} + kind="negative" + /> + </Fragment> + )} + <Part + title="Merchant" + text={contractTerms.merchant.name} + kind="neutral" + /> + <Part title="Purchase" text={contractTerms.summary} kind="neutral" /> + {contractTerms.order_id && ( + <Part + title="Receipt" + text={`#${contractTerms.order_id}`} + kind="neutral" + /> + )} + </section> + <ButtonsSection /> + </WalletAction> + ); } -function amountToString(text: AmountLike) { - const aj = Amounts.jsonifyAmount(text) - const amount = Amounts.stringifyValue(aj, 2) - return `${amount} ${aj.currency}` +function amountToString(text: AmountLike): string { + const aj = Amounts.jsonifyAmount(text); + const amount = Amounts.stringifyValue(aj, 2); + return `${amount} ${aj.currency}`; } diff --git a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx index 88e714cb7..a0abcea58 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.stories.tsx @@ -15,63 +15,61 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { OrderShortInfo } from '@gnu-taler/taler-util'; -import { createExample } from '../test-utils'; -import { View as TestedComponent } from './Refund'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { OrderShortInfo } from "@gnu-taler/taler-util"; +import { createExample } from "../test-utils"; +import { View as TestedComponent } from "./Refund"; export default { - title: 'cta/refund', + title: "cta/refund", component: TestedComponent, - argTypes: { - }, + argTypes: {}, }; export const Complete = createExample(TestedComponent, { applyResult: { - amountEffectivePaid: 'USD:10', - amountRefundGone: 'USD:0', - amountRefundGranted: 'USD:2', - contractTermsHash: 'QWEASDZXC', - info: { - summary: 'tasty cold beer', - contractTermsHash: 'QWEASDZXC', - } as Partial<OrderShortInfo> as any, + amountEffectivePaid: "USD:10", + amountRefundGone: "USD:0", + amountRefundGranted: "USD:2", + contractTermsHash: "QWEASDZXC", + info: ({ + summary: "tasty cold beer", + contractTermsHash: "QWEASDZXC", + } as Partial<OrderShortInfo>) as any, pendingAtExchange: false, proposalId: "proposal123", - } + }, }); export const Partial = createExample(TestedComponent, { applyResult: { - amountEffectivePaid: 'USD:10', - amountRefundGone: 'USD:1', - amountRefundGranted: 'USD:2', - contractTermsHash: 'QWEASDZXC', - info: { - summary: 'tasty cold beer', - contractTermsHash: 'QWEASDZXC', - } as Partial<OrderShortInfo> as any, + amountEffectivePaid: "USD:10", + amountRefundGone: "USD:1", + amountRefundGranted: "USD:2", + contractTermsHash: "QWEASDZXC", + info: ({ + summary: "tasty cold beer", + contractTermsHash: "QWEASDZXC", + } as Partial<OrderShortInfo>) as any, pendingAtExchange: false, proposalId: "proposal123", - } + }, }); export const InProgress = createExample(TestedComponent, { applyResult: { - amountEffectivePaid: 'USD:10', - amountRefundGone: 'USD:1', - amountRefundGranted: 'USD:2', - contractTermsHash: 'QWEASDZXC', - info: { - summary: 'tasty cold beer', - contractTermsHash: 'QWEASDZXC', - } as Partial<OrderShortInfo> as any, + amountEffectivePaid: "USD:10", + amountRefundGone: "USD:1", + amountRefundGranted: "USD:2", + contractTermsHash: "QWEASDZXC", + info: ({ + summary: "tasty cold beer", + contractTermsHash: "QWEASDZXC", + } as Partial<OrderShortInfo>) as any, pendingAtExchange: true, proposalId: "proposal123", - } + }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Refund.tsx b/packages/taler-wallet-webextension/src/cta/Refund.tsx index 943095360..cecd1ac00 100644 --- a/packages/taler-wallet-webextension/src/cta/Refund.tsx +++ b/packages/taler-wallet-webextension/src/cta/Refund.tsx @@ -20,47 +20,47 @@ * @author Florian Dold */ -import * as wxApi from "../wxApi"; -import { AmountView } from "../renderHtml"; -import { - ApplyRefundResponse, - Amounts, -} from "@gnu-taler/taler-util"; +import { Amounts, ApplyRefundResponse } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; -import { JSX } from "preact/jsx-runtime"; -import { h } from 'preact'; +import { AmountView } from "../renderHtml"; +import * as wxApi from "../wxApi"; interface Props { - talerRefundUri?: string + talerRefundUri?: string; } export interface ViewProps { applyResult: ApplyRefundResponse; } -export function View({ applyResult }: ViewProps) { - return <section class="main"> - <h1>GNU Taler Wallet</h1> - <article class="fade"> - <h2>Refund Status</h2> - <p> - The product <em>{applyResult.info.summary}</em> has received a total - effective refund of{" "} - <AmountView amount={applyResult.amountRefundGranted} />. - </p> - {applyResult.pendingAtExchange ? ( - <p>Refund processing is still in progress.</p> - ) : null} - {!Amounts.isZero(applyResult.amountRefundGone) ? ( +export function View({ applyResult }: ViewProps): VNode { + return ( + <section class="main"> + <h1>GNU Taler Wallet</h1> + <article class="fade"> + <h2>Refund Status</h2> <p> - The refund amount of{" "} - <AmountView amount={applyResult.amountRefundGone} />{" "} - could not be applied. + The product <em>{applyResult.info.summary}</em> has received a total + effective refund of{" "} + <AmountView amount={applyResult.amountRefundGranted} />. </p> - ) : null} - </article> - </section> + {applyResult.pendingAtExchange ? ( + <p>Refund processing is still in progress.</p> + ) : null} + {!Amounts.isZero(applyResult.amountRefundGone) ? ( + <p> + The refund amount of{" "} + <AmountView amount={applyResult.amountRefundGone} /> could not be + applied. + </p> + ) : null} + </article> + </section> + ); } -export function RefundPage({ talerRefundUri }: Props): JSX.Element { - const [applyResult, setApplyResult] = useState<ApplyRefundResponse | undefined>(undefined); +export function RefundPage({ talerRefundUri }: Props): VNode { + const [applyResult, setApplyResult] = useState< + ApplyRefundResponse | undefined + >(undefined); const [errMsg, setErrMsg] = useState<string | undefined>(undefined); useEffect(() => { @@ -70,9 +70,10 @@ export function RefundPage({ talerRefundUri }: Props): JSX.Element { const result = await wxApi.applyRefund(talerRefundUri); setApplyResult(result); } catch (e) { - console.error(e); - setErrMsg(e.message); - console.log("err message", e.message); + if (e instanceof Error) { + setErrMsg(e.message); + console.log("err message", e.message); + } } }; doFetch(); diff --git a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx index 389b183f0..8da599513 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Tip.stories.tsx @@ -15,45 +15,43 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample } from '../test-utils'; -import { View as TestedComponent } from './Tip'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample } from "../test-utils"; +import { View as TestedComponent } from "./Tip"; export default { - title: 'cta/tip', + title: "cta/tip", component: TestedComponent, - argTypes: { - }, + argTypes: {}, }; export const Accepted = createExample(TestedComponent, { prepareTipResult: { accepted: true, - merchantBaseUrl: '', - exchangeBaseUrl: '', - expirationTimestamp : { - t_ms: 0 + merchantBaseUrl: "", + exchangeBaseUrl: "", + expirationTimestamp: { + t_ms: 0, }, - tipAmountEffective: 'USD:10', - tipAmountRaw: 'USD:5', - walletTipId: 'id' - } + tipAmountEffective: "USD:10", + tipAmountRaw: "USD:5", + walletTipId: "id", + }, }); export const NotYetAccepted = createExample(TestedComponent, { prepareTipResult: { accepted: false, - merchantBaseUrl: 'http://merchant.url/', - exchangeBaseUrl: 'http://exchange.url/', - expirationTimestamp : { - t_ms: 0 + merchantBaseUrl: "http://merchant.url/", + exchangeBaseUrl: "http://exchange.url/", + expirationTimestamp: { + t_ms: 0, }, - tipAmountEffective: 'USD:10', - tipAmountRaw: 'USD:5', - walletTipId: 'id' - } + tipAmountEffective: "USD:10", + tipAmountRaw: "USD:5", + walletTipId: "id", + }, }); diff --git a/packages/taler-wallet-webextension/src/cta/Tip.tsx b/packages/taler-wallet-webextension/src/cta/Tip.tsx index dc1feaed3..5a9ab720d 100644 --- a/packages/taler-wallet-webextension/src/cta/Tip.tsx +++ b/packages/taler-wallet-webextension/src/cta/Tip.tsx @@ -20,51 +20,54 @@ * @author Florian Dold <dold@taler.net> */ -import { useEffect, useState } from "preact/hooks"; import { PrepareTipResult } from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { AmountView } from "../renderHtml"; import * as wxApi from "../wxApi"; -import { JSX } from "preact/jsx-runtime"; -import { h } from 'preact'; interface Props { - talerTipUri?: string + talerTipUri?: string; } export interface ViewProps { prepareTipResult: PrepareTipResult; onAccept: () => void; onIgnore: () => void; - } -export function View({ prepareTipResult, onAccept, onIgnore }: ViewProps) { - return <section class="main"> - <h1>GNU Taler Wallet</h1> - <article class="fade"> - {prepareTipResult.accepted ? ( - <span> - Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted. Check - your transactions list for more details. - </span> - ) : ( +export function View({ + prepareTipResult, + onAccept, + onIgnore, +}: ViewProps): VNode { + return ( + <section class="main"> + <h1>GNU Taler Wallet</h1> + <article class="fade"> + {prepareTipResult.accepted ? ( + <span> + Tip from <code>{prepareTipResult.merchantBaseUrl}</code> accepted. + Check your transactions list for more details. + </span> + ) : ( <div> <p> The merchant <code>{prepareTipResult.merchantBaseUrl}</code> is - offering you a tip of{" "} + offering you a tip of{" "} <strong> <AmountView amount={prepareTipResult.tipAmountEffective} /> </strong>{" "} - via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code> + via the exchange <code>{prepareTipResult.exchangeBaseUrl}</code> </p> <button onClick={onAccept}>Accept tip</button> <button onClick={onIgnore}>Ignore</button> </div> )} - </article> - </section> - + </article> + </section> + ); } -export function TipPage({ talerTipUri }: Props): JSX.Element { +export function TipPage({ talerTipUri }: Props): VNode { const [updateCounter, setUpdateCounter] = useState<number>(0); const [prepareTipResult, setPrepareTipResult] = useState< PrepareTipResult | undefined @@ -105,7 +108,11 @@ export function TipPage({ talerTipUri }: Props): JSX.Element { return <span>Loading ...</span>; } - return <View prepareTipResult={prepareTipResult} - onAccept={doAccept} onIgnore={doIgnore} - /> + return ( + <View + prepareTipResult={prepareTipResult} + onAccept={doAccept} + onIgnore={doIgnore} + /> + ); } diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx index 5e29a3e39..54ae19c61 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.stories.tsx @@ -15,23 +15,19 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { amountFractionalBase, Amounts } from '@gnu-taler/taler-util'; -import { ExchangeRecord } from '@gnu-taler/taler-wallet-core'; -import { ExchangeWithdrawDetails } from '@gnu-taler/taler-wallet-core/src/operations/withdraw'; -import { getMaxListeners } from 'process'; -import { createExample } from '../test-utils'; -import { View as TestedComponent } from './Withdraw'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { amountFractionalBase } from "@gnu-taler/taler-util"; +import { createExample } from "../test-utils"; +import { View as TestedComponent } from "./Withdraw"; export default { - title: 'cta/withdraw', + title: "cta/withdraw", component: TestedComponent, argTypes: { - onSwitchExchange: { action: 'onRetry' }, + onSwitchExchange: { action: "onRetry" }, }, }; @@ -48,7 +44,7 @@ const termsHtml = `<html xmlns="http://www.w3.org/1999/xhtml" lang="en"> </div> </body> </html> -` +`; const termsPlain = ` Terms Of Service **************** @@ -432,7 +428,7 @@ Questions or comments We welcome comments, questions, concerns, or suggestions. Please send us a message on our contact page at legal@taler-systems.com. -` +`; const termsXml = `<?xml version="1.0" encoding="utf-8"?> <!DOCTYPE document PUBLIC "+//IDN docutils.sourceforge.net//DTD Docutils Generic//EN//XML" "http://docutils.sourceforge.net/docs/ref/docutils.dtd"> @@ -781,120 +777,119 @@ const termsXml = `<?xml version="1.0" encoding="utf-8"?> `; export const NewTerms = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'new' + status: "new", }, -}) +}); export const TermsReviewingPLAIN = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'plain', - content: termsPlain + type: "plain", + content: termsPlain, }, - status: 'new' + status: "new", }, - reviewing: true -}) + reviewing: true, +}); export const TermsReviewingHTML = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'html', - href: new URL(`data:text/html;base64,${Buffer.from(termsHtml).toString('base64')}`), + type: "html", + href: new URL( + `data:text/html;base64,${Buffer.from(termsHtml).toString("base64")}`, + ), }, - status: 'new' + status: "new", }, - reviewing: true -}) + reviewing: true, +}); const termsPdf = ` %PDF-1.2 @@ -909,306 +904,298 @@ endobj trailer << /Root 3 0 R >> %%EOF -` +`; export const TermsReviewingPDF = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'pdf', - location: new URL(`data:text/html;base64,${Buffer.from(termsPdf).toString('base64')}`), + type: "pdf", + location: new URL( + `data:text/html;base64,${Buffer.from(termsPdf).toString("base64")}`, + ), }, - status: 'new' + status: "new", }, - reviewing: true -}) - + reviewing: true, +}); export const TermsReviewingXML = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'new' + status: "new", }, - reviewing: true -}) + reviewing: true, +}); export const NewTermsAccepted = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, + }, + onSwitchExchange: async () => { + null; }, - onSwitchExchange: async () => { }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'new' + status: "new", }, - reviewed: true -}) + reviewed: true, +}); export const TermsShowAgainXML = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'new' + status: "new", }, reviewed: true, reviewing: true, -}) +}); export const TermsChanged = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'changed' + status: "changed", }, -}) +}); export const TermsNotFound = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { - status: 'notfound' + status: "notfound", }, -}) +}); export const TermsAlreadyAccepted = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: amountFractionalBase * 0.5, - value: 0 + value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { - status: 'accepted' + status: "accepted", }, -}) - +}); export const WithoutFee = createExample(TestedComponent, { - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'exchange.demo.taler.net', - paytoUris: ['asd'], - }, { - currency: 'USD', - exchangeBaseUrl: 'exchange.test.taler.net', - paytoUris: ['asd'], - }], - exchangeBaseUrl: 'exchange.demo.taler.net', - details: { - content: '', - contentType: '', - currentEtag: '', - acceptedEtag: undefined, - }, + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "exchange.demo.taler.net", + paytoUris: ["asd"], + }, + { + currency: "USD", + exchangeBaseUrl: "exchange.test.taler.net", + paytoUris: ["asd"], + }, + ], + exchangeBaseUrl: "exchange.demo.taler.net", withdrawalFee: { - currency: 'USD', + currency: "USD", fraction: 0, value: 0, }, amount: { - currency: 'USD', + currency: "USD", value: 2, - fraction: 10000000 + fraction: 10000000, }, - onSwitchExchange: async () => { }, + onSwitchExchange: async () => { + null; + }, terms: { value: { - type: 'xml', + type: "xml", document: new DOMParser().parseFromString(termsXml, "text/xml"), }, - status: 'accepted', - } -})
\ No newline at end of file + status: "accepted", + }, +}); diff --git a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx index 6ef72cbe6..8258717bd 100644 --- a/packages/taler-wallet-webextension/src/cta/Withdraw.tsx +++ b/packages/taler-wallet-webextension/src/cta/Withdraw.tsx @@ -21,28 +21,44 @@ * @author Florian Dold */ -import { AmountJson, Amounts, ExchangeListItem, GetExchangeTosResult, i18n, WithdrawUriInfoResponse } from '@gnu-taler/taler-util'; -import { ExchangeWithdrawDetails } from '@gnu-taler/taler-wallet-core/src/operations/withdraw'; +import { + AmountJson, + Amounts, + ExchangeListItem, + GetExchangeTosResult, + i18n, + WithdrawUriInfoResponse, +} from "@gnu-taler/taler-util"; +import { VNode, h, Fragment } from "preact"; import { useState } from "preact/hooks"; -import { Fragment } from 'preact/jsx-runtime'; -import { CheckboxOutlined } from '../components/CheckboxOutlined'; -import { ExchangeXmlTos } from '../components/ExchangeToS'; -import { LogoHeader } from '../components/LogoHeader'; -import { Part } from '../components/Part'; -import { SelectList } from '../components/SelectList'; -import { ButtonSuccess, ButtonWarning, LinkSuccess, LinkWarning, TermsOfService, WalletAction, WarningText } from '../components/styled'; -import { useAsyncAsHook } from '../hooks/useAsyncAsHook'; +import { CheckboxOutlined } from "../components/CheckboxOutlined"; +import { ExchangeXmlTos } from "../components/ExchangeToS"; +import { LogoHeader } from "../components/LogoHeader"; +import { Part } from "../components/Part"; +import { SelectList } from "../components/SelectList"; +import { + ButtonSuccess, + ButtonWarning, + LinkSuccess, + TermsOfService, + WalletAction, + WarningText, +} from "../components/styled"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook"; import { - acceptWithdrawal, getExchangeWithdrawalInfo, getWithdrawalDetailsForUri, setExchangeTosAccepted, listExchanges, getExchangeTos + acceptWithdrawal, + getExchangeTos, + getExchangeWithdrawalInfo, + getWithdrawalDetailsForUri, + listExchanges, + setExchangeTosAccepted, } from "../wxApi"; -import { wxMain } from '../wxBackend.js'; interface Props { talerWithdrawUri?: string; } export interface ViewProps { - details: GetExchangeTosResult; withdrawalFee: AmountJson; exchangeBaseUrl: string; amount: AmountJson; @@ -58,145 +74,192 @@ export interface ViewProps { status: TermsStatus; }; knownExchanges: ExchangeListItem[]; +} -}; - -type TermsStatus = 'new' | 'accepted' | 'changed' | 'notfound'; +type TermsStatus = "new" | "accepted" | "changed" | "notfound"; -type TermsDocument = TermsDocumentXml | TermsDocumentHtml | TermsDocumentPlain | TermsDocumentJson | TermsDocumentPdf; +type TermsDocument = + | TermsDocumentXml + | TermsDocumentHtml + | TermsDocumentPlain + | TermsDocumentJson + | TermsDocumentPdf; interface TermsDocumentXml { - type: 'xml', - document: Document, + type: "xml"; + document: Document; } interface TermsDocumentHtml { - type: 'html', - href: URL, + type: "html"; + href: URL; } interface TermsDocumentPlain { - type: 'plain', - content: string, + type: "plain"; + content: string; } interface TermsDocumentJson { - type: 'json', - data: any, + type: "json"; + data: any; } interface TermsDocumentPdf { - type: 'pdf', - location: URL, + type: "pdf"; + location: URL; } -function amountToString(text: AmountJson) { - const aj = Amounts.jsonifyAmount(text) - const amount = Amounts.stringifyValue(aj) - return `${amount} ${aj.currency}` +function amountToString(text: AmountJson): string { + const aj = Amounts.jsonifyAmount(text); + const amount = Amounts.stringifyValue(aj); + return `${amount} ${aj.currency}`; } -export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, amount, onWithdraw, onSwitchExchange, terms, reviewing, onReview, onAccept, reviewed, confirmed }: ViewProps) { - const needsReview = terms.status === 'changed' || terms.status === 'new' - - const [switchingExchange, setSwitchingExchange] = useState<string | undefined>(undefined) - const exchanges = knownExchanges.reduce((prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), {}) +export function View({ + withdrawalFee, + exchangeBaseUrl, + knownExchanges, + amount, + onWithdraw, + onSwitchExchange, + terms, + reviewing, + onReview, + onAccept, + reviewed, + confirmed, +}: ViewProps): VNode { + const needsReview = terms.status === "changed" || terms.status === "new"; + + const [switchingExchange, setSwitchingExchange] = useState< + string | undefined + >(undefined); + const exchanges = knownExchanges.reduce( + (prev, ex) => ({ ...prev, [ex.exchangeBaseUrl]: ex.exchangeBaseUrl }), + {}, + ); return ( <WalletAction> <LogoHeader /> - <h2> - {i18n.str`Digital cash withdrawal`} - </h2> + <h2>{i18n.str`Digital cash withdrawal`}</h2> <section> - <Part title="Total to withdraw" text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} kind='positive' /> - <Part title="Chosen amount" text={amountToString(amount)} kind='neutral' /> - {Amounts.isNonZero(withdrawalFee) && - <Part title="Exchange fee" text={amountToString(withdrawalFee)} kind='negative' /> - } - <Part title="Exchange" text={exchangeBaseUrl} kind='neutral' big /> + <Part + title="Total to withdraw" + text={amountToString(Amounts.sub(amount, withdrawalFee).amount)} + kind="positive" + /> + <Part + title="Chosen amount" + text={amountToString(amount)} + kind="neutral" + /> + {Amounts.isNonZero(withdrawalFee) && ( + <Part + title="Exchange fee" + text={amountToString(withdrawalFee)} + kind="negative" + /> + )} + <Part title="Exchange" text={exchangeBaseUrl} kind="neutral" big /> </section> - {!reviewing && + {!reviewing && ( <section> - {switchingExchange !== undefined ? <Fragment> - <div> - <SelectList label="Known exchanges" list={exchanges} name="" onChange={onSwitchExchange} /> - </div> - <LinkSuccess upperCased onClick={() => onSwitchExchange(switchingExchange)}> - {i18n.str`Confirm exchange selection`} - </LinkSuccess> - </Fragment> - : <LinkSuccess upperCased onClick={() => setSwitchingExchange("")}> + {switchingExchange !== undefined ? ( + <Fragment> + <div> + <SelectList + label="Known exchanges" + list={exchanges} + name="" + onChange={onSwitchExchange} + /> + </div> + <LinkSuccess + upperCased + onClick={() => onSwitchExchange(switchingExchange)} + > + {i18n.str`Confirm exchange selection`} + </LinkSuccess> + </Fragment> + ) : ( + <LinkSuccess upperCased onClick={() => setSwitchingExchange("")}> {i18n.str`Switch exchange`} - </LinkSuccess>} - + </LinkSuccess> + )} </section> - } - {!reviewing && reviewed && + )} + {!reviewing && reviewed && ( <section> - <LinkSuccess - upperCased - onClick={() => onReview(true)} - > + <LinkSuccess upperCased onClick={() => onReview(true)}> {i18n.str`Show terms of service`} </LinkSuccess> </section> - } - {terms.status === 'notfound' && + )} + {terms.status === "notfound" && ( <section> <WarningText> {i18n.str`Exchange doesn't have terms of service`} </WarningText> </section> - } - {reviewing && + )} + {reviewing && ( <section> - {terms.status !== 'accepted' && terms.value && terms.value.type === 'xml' && - <TermsOfService> - <ExchangeXmlTos doc={terms.value.document} /> - </TermsOfService> - } - {terms.status !== 'accepted' && terms.value && terms.value.type === 'plain' && - <div style={{ textAlign: 'left' }}> - <pre>{terms.value.content}</pre> - </div> - } - {terms.status !== 'accepted' && terms.value && terms.value.type === 'html' && - <iframe src={terms.value.href.toString()} /> - } - {terms.status !== 'accepted' && terms.value && terms.value.type === 'pdf' && - <a href={terms.value.location.toString()} download="tos.pdf" >Download Terms of Service</a> - } - </section>} - {reviewing && reviewed && + {terms.status !== "accepted" && + terms.value && + terms.value.type === "xml" && ( + <TermsOfService> + <ExchangeXmlTos doc={terms.value.document} /> + </TermsOfService> + )} + {terms.status !== "accepted" && + terms.value && + terms.value.type === "plain" && ( + <div style={{ textAlign: "left" }}> + <pre>{terms.value.content}</pre> + </div> + )} + {terms.status !== "accepted" && + terms.value && + terms.value.type === "html" && ( + <iframe src={terms.value.href.toString()} /> + )} + {terms.status !== "accepted" && + terms.value && + terms.value.type === "pdf" && ( + <a href={terms.value.location.toString()} download="tos.pdf"> + Download Terms of Service + </a> + )} + </section> + )} + {reviewing && reviewed && ( <section> - <LinkSuccess - upperCased - onClick={() => onReview(false)} - > + <LinkSuccess upperCased onClick={() => onReview(false)}> {i18n.str`Hide terms of service`} </LinkSuccess> </section> - } - {(reviewing || reviewed) && + )} + {(reviewing || reviewed) && ( <section> <CheckboxOutlined name="terms" enabled={reviewed} label={i18n.str`I accept the exchange terms of service`} onToggle={() => { - onAccept(!reviewed) - onReview(false) + onAccept(!reviewed); + onReview(false); }} /> </section> - } + )} {/** * Main action section */} <section> - {terms.status === 'new' && !reviewed && !reviewing && + {terms.status === "new" && !reviewed && !reviewing && ( <ButtonSuccess upperCased disabled={!exchangeBaseUrl} @@ -204,8 +267,8 @@ export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, > {i18n.str`Review exchange terms of service`} </ButtonSuccess> - } - {terms.status === 'changed' && !reviewed && !reviewing && + )} + {terms.status === "changed" && !reviewed && !reviewing && ( <ButtonWarning upperCased disabled={!exchangeBaseUrl} @@ -213,8 +276,8 @@ export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, > {i18n.str`Review new version of terms of service`} </ButtonWarning> - } - {(terms.status === 'accepted' || (needsReview && reviewed)) && + )} + {(terms.status === "accepted" || (needsReview && reviewed)) && ( <ButtonSuccess upperCased disabled={!exchangeBaseUrl || confirmed} @@ -222,8 +285,8 @@ export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, > {i18n.str`Confirm withdrawal`} </ButtonSuccess> - } - {terms.status === 'notfound' && + )} + {terms.status === "notfound" && ( <ButtonWarning upperCased disabled={!exchangeBaseUrl} @@ -231,60 +294,88 @@ export function View({ details, withdrawalFee, exchangeBaseUrl, knownExchanges, > {i18n.str`Withdraw anyway`} </ButtonWarning> - } + )} </section> </WalletAction> - ) + ); } -export function WithdrawPageWithParsedURI({ uri, uriInfo }: { uri: string, uriInfo: WithdrawUriInfoResponse }) { - const [customExchange, setCustomExchange] = useState<string | undefined>(undefined) - const [errorAccepting, setErrorAccepting] = useState<string | undefined>(undefined) - - const [reviewing, setReviewing] = useState<boolean>(false) - const [reviewed, setReviewed] = useState<boolean>(false) - const [confirmed, setConfirmed] = useState<boolean>(false) - - const knownExchangesHook = useAsyncAsHook(() => listExchanges()) - - const knownExchanges = !knownExchangesHook || knownExchangesHook.hasError ? [] : knownExchangesHook.response.exchanges - const withdrawAmount = Amounts.parseOrThrow(uriInfo.amount) - const thisCurrencyExchanges = knownExchanges.filter(ex => ex.currency === withdrawAmount.currency) - - const exchange = customExchange || uriInfo.defaultExchangeBaseUrl || thisCurrencyExchanges[0]?.exchangeBaseUrl +export function WithdrawPageWithParsedURI({ + uri, + uriInfo, +}: { + uri: string; + uriInfo: WithdrawUriInfoResponse; +}): VNode { + const [customExchange, setCustomExchange] = useState<string | undefined>( + undefined, + ); + const [errorAccepting, setErrorAccepting] = useState<string | undefined>( + undefined, + ); + + const [reviewing, setReviewing] = useState<boolean>(false); + const [reviewed, setReviewed] = useState<boolean>(false); + const [confirmed, setConfirmed] = useState<boolean>(false); + + const knownExchangesHook = useAsyncAsHook(() => listExchanges()); + + const knownExchanges = + !knownExchangesHook || knownExchangesHook.hasError + ? [] + : knownExchangesHook.response.exchanges; + const withdrawAmount = Amounts.parseOrThrow(uriInfo.amount); + const thisCurrencyExchanges = knownExchanges.filter( + (ex) => ex.currency === withdrawAmount.currency, + ); + + const exchange = + customExchange || + uriInfo.defaultExchangeBaseUrl || + thisCurrencyExchanges[0]?.exchangeBaseUrl; const detailsHook = useAsyncAsHook(async () => { - if (!exchange) throw Error('no default exchange') - const tos = await getExchangeTos(exchange, ['text/xml']) + if (!exchange) throw Error("no default exchange"); + const tos = await getExchangeTos(exchange, ["text/xml"]); const info = await getExchangeWithdrawalInfo({ exchangeBaseUrl: exchange, amount: withdrawAmount, - tosAcceptedFormat: ['text/xml'] - }) - return { tos, info } - }) + tosAcceptedFormat: ["text/xml"], + }); + return { tos, info }; + }); if (!detailsHook) { - return <span><i18n.Translate>Getting withdrawal details.</i18n.Translate></span>; + return ( + <span> + <i18n.Translate>Getting withdrawal details.</i18n.Translate> + </span> + ); } if (detailsHook.hasError) { - return <span><i18n.Translate>Problems getting details: {detailsHook.message}</i18n.Translate></span>; + return ( + <span> + <i18n.Translate> + Problems getting details: {detailsHook.message} + </i18n.Translate> + </span> + ); } - const details = detailsHook.response + const details = detailsHook.response; const onAccept = async (): Promise<void> => { try { - await setExchangeTosAccepted(exchange, details.tos.currentEtag) - setReviewed(true) + await setExchangeTosAccepted(exchange, details.tos.currentEtag); + setReviewed(true); } catch (e) { if (e instanceof Error) { - setErrorAccepting(e.message) + setErrorAccepting(e.message); } } - } + }; const onWithdraw = async (): Promise<void> => { - setConfirmed(true) + setConfirmed(true); console.log("accepting exchange", exchange); try { const res = await acceptWithdrawal(uri, exchange); @@ -293,91 +384,121 @@ export function WithdrawPageWithParsedURI({ uri, uriInfo }: { uri: string, uriIn document.location.href = res.confirmTransferUrl; } } catch (e) { - setConfirmed(false) + setConfirmed(false); } }; - const termsContent: TermsDocument | undefined = parseTermsOfServiceContent(details.tos.contentType, details.tos.content); - - const status: TermsStatus = !termsContent ? 'notfound' : ( - !details.tos.acceptedEtag ? 'new' : ( - details.tos.acceptedEtag !== details.tos.currentEtag ? 'changed' : 'accepted' - )) - - - return <View onWithdraw={onWithdraw} - details={details.tos} amount={withdrawAmount} - exchangeBaseUrl={exchange} - withdrawalFee={details.info.withdrawFee} //FIXME - terms={{ - status, value: termsContent - }} - onSwitchExchange={setCustomExchange} - knownExchanges={knownExchanges} - confirmed={confirmed} - reviewed={reviewed} onAccept={onAccept} - reviewing={reviewing} onReview={setReviewing} - /> + const termsContent: TermsDocument | undefined = parseTermsOfServiceContent( + details.tos.contentType, + details.tos.content, + ); + + const status: TermsStatus = !termsContent + ? "notfound" + : !details.tos.acceptedEtag + ? "new" + : details.tos.acceptedEtag !== details.tos.currentEtag + ? "changed" + : "accepted"; + + return ( + <View + onWithdraw={onWithdraw} + // details={details.tos} + amount={withdrawAmount} + exchangeBaseUrl={exchange} + withdrawalFee={details.info.withdrawFee} //FIXME + terms={{ + status, + value: termsContent, + }} + onSwitchExchange={setCustomExchange} + knownExchanges={knownExchanges} + confirmed={confirmed} + reviewed={reviewed} + onAccept={onAccept} + reviewing={reviewing} + onReview={setReviewing} + /> + ); } -export function WithdrawPage({ talerWithdrawUri }: Props): JSX.Element { - const uriInfoHook = useAsyncAsHook(() => !talerWithdrawUri ? Promise.reject(undefined) : - getWithdrawalDetailsForUri({ talerWithdrawUri }) - ) +export function WithdrawPage({ talerWithdrawUri }: Props): VNode { + const uriInfoHook = useAsyncAsHook(() => + !talerWithdrawUri + ? Promise.reject(undefined) + : getWithdrawalDetailsForUri({ talerWithdrawUri }), + ); if (!talerWithdrawUri) { - return <span><i18n.Translate>missing withdraw uri</i18n.Translate></span>; + return ( + <span> + <i18n.Translate>missing withdraw uri</i18n.Translate> + </span> + ); } if (!uriInfoHook) { - return <span><i18n.Translate>Loading...</i18n.Translate></span>; + return ( + <span> + <i18n.Translate>Loading...</i18n.Translate> + </span> + ); } if (uriInfoHook.hasError) { - return <span><i18n.Translate>This URI is not valid anymore: {uriInfoHook.message}</i18n.Translate></span>; + return ( + <span> + <i18n.Translate> + This URI is not valid anymore: {uriInfoHook.message} + </i18n.Translate> + </span> + ); } - return <WithdrawPageWithParsedURI uri={talerWithdrawUri} uriInfo={uriInfoHook.response} /> + return ( + <WithdrawPageWithParsedURI + uri={talerWithdrawUri} + uriInfo={uriInfoHook.response} + /> + ); } -function parseTermsOfServiceContent(type: string, text: string): TermsDocument | undefined { - if (type === 'text/xml') { +function parseTermsOfServiceContent( + type: string, + text: string, +): TermsDocument | undefined { + if (type === "text/xml") { try { - const document = new DOMParser().parseFromString(text, "text/xml") - return { type: 'xml', document } + const document = new DOMParser().parseFromString(text, "text/xml"); + return { type: "xml", document }; } catch (e) { - console.log(e) - debugger; + console.log(e); } - } else if (type === 'text/html') { + } else if (type === "text/html") { try { - const href = new URL(text) - return { type: 'html', href } + const href = new URL(text); + return { type: "html", href }; } catch (e) { - console.log(e) - debugger; + console.log(e); } - } else if (type === 'text/json') { + } else if (type === "text/json") { try { - const data = JSON.parse(text) - return { type: 'json', data } + const data = JSON.parse(text); + return { type: "json", data }; } catch (e) { - console.log(e) - debugger; + console.log(e); } - } else if (type === 'text/pdf') { + } else if (type === "text/pdf") { try { - const location = new URL(text) - return { type: 'pdf', location } + const location = new URL(text); + return { type: "pdf", location }; } catch (e) { - console.log(e) - debugger; + console.log(e); } - } else if (type === 'text/plain') { + } else if (type === "text/plain") { try { - const content = text - return { type: 'plain', content } + const content = text; + return { type: "plain", content }; } catch (e) { - console.log(e) - debugger; + console.log(e); } } - return undefined + return undefined; } - diff --git a/packages/taler-wallet-webextension/src/cta/payback.tsx b/packages/taler-wallet-webextension/src/cta/payback.tsx index 1e27fd912..1c81b48a0 100644 --- a/packages/taler-wallet-webextension/src/cta/payback.tsx +++ b/packages/taler-wallet-webextension/src/cta/payback.tsx @@ -14,8 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX } from "preact/jsx-runtime"; -import { h } from 'preact'; +import { h, VNode } from "preact"; /** * View and edit auditors. @@ -27,6 +26,6 @@ import { h } from 'preact'; * Imports. */ -export function makePaybackPage(): JSX.Element { +export function makePaybackPage(): VNode { return <div>not implemented</div>; } diff --git a/packages/taler-wallet-webextension/src/cta/reset-required.tsx b/packages/taler-wallet-webextension/src/cta/reset-required.tsx index e66c0db57..75c4c1962 100644 --- a/packages/taler-wallet-webextension/src/cta/reset-required.tsx +++ b/packages/taler-wallet-webextension/src/cta/reset-required.tsx @@ -20,7 +20,7 @@ * @author Florian Dold */ -import { Component, JSX, h } from "preact"; +import { Component, h, VNode } from "preact"; import * as wxApi from "../wxApi"; interface State { @@ -45,7 +45,7 @@ class ResetNotification extends Component<any, State> { const res = await wxApi.checkUpgrade(); this.setState({ resetRequired: res.dbResetRequired }); } - render(): JSX.Element { + render(): VNode { if (this.state.resetRequired) { return ( <div> @@ -63,7 +63,7 @@ class ResetNotification extends Component<any, State> { type="checkbox" checked={this.state.checked} onChange={() => { - this.setState(prev => ({ checked: prev.checked })) + this.setState((prev) => ({ checked: prev.checked })); }} />{" "} <label htmlFor="check"> @@ -92,6 +92,6 @@ class ResetNotification extends Component<any, State> { /** * @deprecated to be removed */ -export function createResetRequiredPage(): JSX.Element { +export function createResetRequiredPage(): VNode { return <ResetNotification />; } diff --git a/packages/taler-wallet-webextension/src/cta/return-coins.tsx b/packages/taler-wallet-webextension/src/cta/return-coins.tsx index 43d73b5fe..55f0297d4 100644 --- a/packages/taler-wallet-webextension/src/cta/return-coins.tsx +++ b/packages/taler-wallet-webextension/src/cta/return-coins.tsx @@ -14,8 +14,7 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX } from "preact/jsx-runtime"; -import { h } from 'preact'; +import { h, VNode } from "preact"; /** * Return coins to own bank account. * @@ -25,6 +24,6 @@ import { h } from 'preact'; /** * Imports. */ -export function createReturnCoinsPage(): JSX.Element { +export function createReturnCoinsPage(): VNode { return <span>Not implemented yet.</span>; } diff --git a/packages/taler-wallet-webextension/src/custom.d.ts b/packages/taler-wallet-webextension/src/custom.d.ts index 1981067d4..521b824c7 100644 --- a/packages/taler-wallet-webextension/src/custom.d.ts +++ b/packages/taler-wallet-webextension/src/custom.d.ts @@ -21,7 +21,7 @@ declare module "*.png" { const content: any; export default content; } -declare module '*.svg' { +declare module "*.svg" { const content: any; export default content; } diff --git a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts index 2131d45cb..aa6695c3e 100644 --- a/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts +++ b/packages/taler-wallet-webextension/src/hooks/useAsyncAsHook.ts @@ -29,7 +29,7 @@ interface HookError { export type HookResponse<T> = HookOk<T> | HookError | undefined; -export function useAsyncAsHook<T> (fn: (() => Promise<T>)): HookResponse<T> { +export function useAsyncAsHook<T>(fn: () => Promise<T>): HookResponse<T> { const [result, setHookResponse] = useState<HookResponse<T>>(undefined); useEffect(() => { async function doAsync() { @@ -42,7 +42,7 @@ export function useAsyncAsHook<T> (fn: (() => Promise<T>)): HookResponse<T> { } } } - doAsync() + doAsync(); }, []); return result; } diff --git a/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts index f3b1b3b5f..1aa711a90 100644 --- a/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts +++ b/packages/taler-wallet-webextension/src/hooks/useBackupDeviceName.ts @@ -17,34 +17,31 @@ import { useEffect, useState } from "preact/hooks"; import * as wxApi from "../wxApi"; - export interface BackupDeviceName { name: string; - update: (s:string) => Promise<void> + update: (s: string) => Promise<void>; } - export function useBackupDeviceName(): BackupDeviceName { const [status, setStatus] = useState<BackupDeviceName>({ - name: '', - update: () => Promise.resolve() - }) + name: "", + update: () => Promise.resolve(), + }); useEffect(() => { async function run() { //create a first list of backup info by currency - const status = await wxApi.getBackupInfo() + const status = await wxApi.getBackupInfo(); async function update(newName: string) { - await wxApi.setWalletDeviceId(newName) - setStatus(old => ({ ...old, name: newName })) + await wxApi.setWalletDeviceId(newName); + setStatus((old) => ({ ...old, name: newName })); } - setStatus({ name: status.deviceId, update }) + setStatus({ name: status.deviceId, update }); } - run() - }, []) + run(); + }, []); - return status + return status; } - diff --git a/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts b/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts index c46ab6a5f..8a8fd6f2f 100644 --- a/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts +++ b/packages/taler-wallet-webextension/src/hooks/useBackupStatus.ts @@ -14,11 +14,15 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { ProviderInfo, ProviderPaymentPaid, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { + ProviderInfo, + ProviderPaymentPaid, + ProviderPaymentStatus, + ProviderPaymentType, +} from "@gnu-taler/taler-wallet-core"; import { useEffect, useState } from "preact/hooks"; import * as wxApi from "../wxApi"; - export interface BackupStatus { deviceName: string; providers: ProviderInfo[]; @@ -32,40 +36,46 @@ function getStatusTypeOrder(t: ProviderPaymentStatus) { ProviderPaymentType.Unpaid, ProviderPaymentType.Paid, ProviderPaymentType.Pending, - ].indexOf(t.type) + ].indexOf(t.type); } function getStatusPaidOrder(a: ProviderPaymentPaid, b: ProviderPaymentPaid) { - return a.paidUntil.t_ms === 'never' ? -1 : - b.paidUntil.t_ms === 'never' ? 1 : - a.paidUntil.t_ms - b.paidUntil.t_ms + return a.paidUntil.t_ms === "never" + ? -1 + : b.paidUntil.t_ms === "never" + ? 1 + : a.paidUntil.t_ms - b.paidUntil.t_ms; } export function useBackupStatus(): BackupStatus | undefined { - const [status, setStatus] = useState<BackupStatus | undefined>(undefined) + const [status, setStatus] = useState<BackupStatus | undefined>(undefined); useEffect(() => { async function run() { //create a first list of backup info by currency - const status = await wxApi.getBackupInfo() + const status = await wxApi.getBackupInfo(); const providers = status.providers.sort((a, b) => { - if (a.paymentStatus.type === ProviderPaymentType.Paid && b.paymentStatus.type === ProviderPaymentType.Paid) { - return getStatusPaidOrder(a.paymentStatus, b.paymentStatus) + if ( + a.paymentStatus.type === ProviderPaymentType.Paid && + b.paymentStatus.type === ProviderPaymentType.Paid + ) { + return getStatusPaidOrder(a.paymentStatus, b.paymentStatus); } - return getStatusTypeOrder(a.paymentStatus) - getStatusTypeOrder(b.paymentStatus) - }) + return ( + getStatusTypeOrder(a.paymentStatus) - + getStatusTypeOrder(b.paymentStatus) + ); + }); async function sync() { - await wxApi.syncAllProviders() + await wxApi.syncAllProviders(); } - - setStatus({ deviceName: status.deviceId, providers, sync }) + + setStatus({ deviceName: status.deviceId, providers, sync }); } - run() - }, []) + run(); + }, []); - return status + return status; } - - diff --git a/packages/taler-wallet-webextension/src/hooks/useBalances.ts b/packages/taler-wallet-webextension/src/hooks/useBalances.ts index 37424fb05..403ce7b87 100644 --- a/packages/taler-wallet-webextension/src/hooks/useBalances.ts +++ b/packages/taler-wallet-webextension/src/hooks/useBalances.ts @@ -18,7 +18,6 @@ import { BalancesResponse } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; import * as wxApi from "../wxApi"; - interface BalancesHookOk { hasError: false; response: BalancesResponse; @@ -46,7 +45,7 @@ export function useBalances(): BalancesHook { } } } - checkBalance() + checkBalance(); return wxApi.onUpdateNotification(checkBalance); }, []); diff --git a/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts b/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts index 888d4d5f1..48aff2602 100644 --- a/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts +++ b/packages/taler-wallet-webextension/src/hooks/useDiagnostics.ts @@ -21,7 +21,7 @@ import * as wxApi from "../wxApi"; export function useDiagnostics(): [WalletDiagnostics | undefined, boolean] { const [timedOut, setTimedOut] = useState(false); const [diagnostics, setDiagnostics] = useState<WalletDiagnostics | undefined>( - undefined + undefined, ); useEffect(() => { @@ -41,5 +41,5 @@ export function useDiagnostics(): [WalletDiagnostics | undefined, boolean] { console.log("fetching diagnostics"); doFetch(); }, []); - return [diagnostics, timedOut] -}
\ No newline at end of file + return [diagnostics, timedOut]; +} diff --git a/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts b/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts index a92425760..aaab0aa43 100644 --- a/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts +++ b/packages/taler-wallet-webextension/src/hooks/useExtendedPermissions.ts @@ -19,13 +19,12 @@ import * as wxApi from "../wxApi"; import { getPermissionsApi } from "../compat"; import { extendedPermissions } from "../permissions"; - export function useExtendedPermissions(): [boolean, () => void] { const [enabled, setEnabled] = useState(false); const toggle = () => { - setEnabled(v => !v); - handleExtendedPerm(enabled).then(result => { + setEnabled((v) => !v); + handleExtendedPerm(enabled).then((result) => { setEnabled(result); }); }; @@ -65,5 +64,5 @@ async function handleExtendedPerm(isEnabled: boolean): Promise<boolean> { nextVal = res.newValue; } console.log("new permissions applied:", nextVal ?? false); - return nextVal ?? false -}
\ No newline at end of file + return nextVal ?? false; +} diff --git a/packages/taler-wallet-webextension/src/hooks/useLang.ts b/packages/taler-wallet-webextension/src/hooks/useLang.ts index 70b9614f6..cc4ff3fc8 100644 --- a/packages/taler-wallet-webextension/src/hooks/useLang.ts +++ b/packages/taler-wallet-webextension/src/hooks/useLang.ts @@ -14,10 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useNotNullLocalStorage } from './useLocalStorage'; +import { useNotNullLocalStorage } from "./useLocalStorage"; -export function useLang(initial?: string): [string, (s:string) => void] { - const browserLang: string | undefined = typeof window !== "undefined" ? navigator.language || (navigator as any).userLanguage : undefined; - const defaultLang = (browserLang || initial || 'en').substring(0, 2) - return useNotNullLocalStorage('lang-preference', defaultLang) +export function useLang(initial?: string): [string, (s: string) => void] { + const browserLang: string | undefined = + typeof window !== "undefined" + ? navigator.language || (navigator as any).userLanguage + : undefined; + const defaultLang = (browserLang || initial || "en").substring(0, 2); + return useNotNullLocalStorage("lang-preference", defaultLang); } diff --git a/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts b/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts index 78a8b65d5..3883aff04 100644 --- a/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts +++ b/packages/taler-wallet-webextension/src/hooks/useLocalStorage.ts @@ -15,38 +15,52 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { StateUpdater, useState } from "preact/hooks"; -export function useLocalStorage(key: string, initialValue?: string): [string | undefined, StateUpdater<string | undefined>] { - const [storedValue, setStoredValue] = useState<string | undefined>((): string | undefined => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; +export function useLocalStorage( + key: string, + initialValue?: string, +): [string | undefined, StateUpdater<string | undefined>] { + const [storedValue, setStoredValue] = useState<string | undefined>((): + | string + | undefined => { + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); - const setValue = (value?: string | ((val?: string) => string | undefined)) => { - setStoredValue(p => { - const toStore = value instanceof Function ? value(p) : value + const setValue = ( + value?: string | ((val?: string) => string | undefined), + ) => { + setStoredValue((p) => { + const toStore = value instanceof Function ? value(p) : value; if (typeof window !== "undefined") { if (!toStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, toStore); } } - return toStore - }) + return toStore; + }); }; return [storedValue, setValue]; } //TODO: merge with the above function -export function useNotNullLocalStorage(key: string, initialValue: string): [string, StateUpdater<string>] { +export function useNotNullLocalStorage( + key: string, + initialValue: string, +): [string, StateUpdater<string>] { const [storedValue, setStoredValue] = useState<string>((): string => { - return typeof window !== "undefined" ? window.localStorage.getItem(key) || initialValue : initialValue; + return typeof window !== "undefined" + ? window.localStorage.getItem(key) || initialValue + : initialValue; }); const setValue = (value: string | ((val: string) => string)) => { @@ -54,7 +68,7 @@ export function useNotNullLocalStorage(key: string, initialValue: string): [stri setStoredValue(valueToStore); if (typeof window !== "undefined") { if (!valueToStore) { - window.localStorage.removeItem(key) + window.localStorage.removeItem(key); } else { window.localStorage.setItem(key, valueToStore); } diff --git a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts index 6520848a5..ea167463e 100644 --- a/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts +++ b/packages/taler-wallet-webextension/src/hooks/useProviderStatus.ts @@ -32,7 +32,9 @@ export function useProviderStatus(url: string): ProviderStatus | undefined { //create a first list of backup info by currency const status = await wxApi.getBackupInfo(); - const providers = status.providers.filter(p => p.syncProviderBaseUrl === url); + const providers = status.providers.filter( + (p) => p.syncProviderBaseUrl === url, + ); const info = providers.length ? providers[0] : undefined; async function sync() { diff --git a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts index ff9cc029a..96a278401 100644 --- a/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts +++ b/packages/taler-wallet-webextension/src/hooks/useTalerActionURL.ts @@ -17,15 +17,18 @@ import { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; -export function useTalerActionURL(): [string | undefined, (s: boolean) => void] { +export function useTalerActionURL(): [ + string | undefined, + (s: boolean) => void, +] { const [talerActionUrl, setTalerActionUrl] = useState<string | undefined>( - undefined + undefined, ); const [dismissed, setDismissed] = useState(false); useEffect(() => { async function check(): Promise<void> { const talerUri = await findTalerUriInActiveTab(); - setTalerActionUrl(talerUri) + setTalerActionUrl(talerUri); } check(); }, []); diff --git a/packages/taler-wallet-webextension/src/i18n/strings.ts b/packages/taler-wallet-webextension/src/i18n/strings.ts index 5b1257830..0fefb0f70 100644 --- a/packages/taler-wallet-webextension/src/i18n/strings.ts +++ b/packages/taler-wallet-webextension/src/i18n/strings.ts @@ -193,7 +193,7 @@ strings["es"] = { "Order redirected": [""], "Payment aborted": [""], "Payment Sent": [""], - "Backup": ["Resguardo"], + Backup: ["Resguardo"], "Order accepted": [""], "Reserve balance updated": [""], "Payment refund": [""], diff --git a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx b/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx index d256f6d98..232b0da73 100644 --- a/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Backup.stories.tsx @@ -15,179 +15,184 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core'; -import { addDays } from 'date-fns'; -import { BackupView as TestedComponent } from './BackupPage'; -import { createExample } from '../test-utils'; +import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { addDays } from "date-fns"; +import { BackupView as TestedComponent } from "./BackupPage"; +import { createExample } from "../test-utils"; export default { - title: 'popup/backup/list', + title: "popup/backup/list", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const LotOfProviders = createExample(TestedComponent, { - providers: [{ - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 + providers: [ + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": addDays(new Date(), 13).getTime() - } + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: addDays(new Date(), 13).getTime(), + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Pending, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Pending, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.InsufficientBalance, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.InsufficientBalance, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.TermsChanged, - newTerms: { - annualFee: 'USD:2', - storageLimitInMegabytes: 8, - supportedProtocolVersion: '2', - }, - oldTerms: { - annualFee: 'USD:1', + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.TermsChanged, + newTerms: { + annualFee: "USD:2", + storageLimitInMegabytes: 8, + supportedProtocolVersion: "2", + }, + oldTerms: { + annualFee: "USD:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "1", + }, + paidUntil: { + t_ms: "never", + }, + }, + terms: { + annualFee: "KUDOS:0.1", storageLimitInMegabytes: 16, - supportedProtocolVersion: '1', - + supportedProtocolVersion: "0.0", }, - paidUntil: { - t_ms: 'never' - } }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }] + ], }); - export const OneProvider = createExample(TestedComponent, { - providers: [{ - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + providers: [ + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }] + ], }); - export const Empty = createExample(TestedComponent, { - providers: [] + providers: [], }); - diff --git a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx b/packages/taler-wallet-webextension/src/popup/BackupPage.tsx index dcc5e5313..ae93f8a40 100644 --- a/packages/taler-wallet-webextension/src/popup/BackupPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BackupPage.tsx @@ -14,15 +14,28 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { i18n, Timestamp } from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentStatus } from "@gnu-taler/taler-wallet-core"; -import { differenceInMonths, formatDuration, intervalToDuration } from "date-fns"; -import { Fragment, JSX, VNode, h } from "preact"; import { - BoldLight, ButtonPrimary, ButtonSuccess, Centered, - CenteredText, CenteredBoldText, PopupBox, RowBorderGray, - SmallText, SmallLightText + ProviderInfo, + ProviderPaymentStatus, +} from "@gnu-taler/taler-wallet-core"; +import { + differenceInMonths, + formatDuration, + intervalToDuration, +} from "date-fns"; +import { Fragment, h, VNode } from "preact"; +import { + BoldLight, + ButtonPrimary, + ButtonSuccess, + Centered, + CenteredBoldText, + CenteredText, + PopupBox, + RowBorderGray, + SmallLightText, + SmallText, } from "../components/styled"; import { useBackupStatus } from "../hooks/useBackupStatus"; import { Pages } from "../NavigationBar"; @@ -32,49 +45,69 @@ interface Props { } export function BackupPage({ onAddProvider }: Props): VNode { - const status = useBackupStatus() + const status = useBackupStatus(); if (!status) { - return <div>Loading...</div> + return <div>Loading...</div>; } - return <BackupView providers={status.providers} onAddProvider={onAddProvider} onSyncAll={status.sync} />; + return ( + <BackupView + providers={status.providers} + onAddProvider={onAddProvider} + onSyncAll={status.sync} + /> + ); } export interface ViewProps { - providers: ProviderInfo[], + providers: ProviderInfo[]; onAddProvider: () => void; onSyncAll: () => Promise<void>; } -export function BackupView({ providers, onAddProvider, onSyncAll }: ViewProps): VNode { +export function BackupView({ + providers, + onAddProvider, + onSyncAll, +}: ViewProps): VNode { return ( <PopupBox> <section> - {providers.map((provider) => <BackupLayout - status={provider.paymentStatus} - timestamp={provider.lastSuccessfulBackupTimestamp} - id={provider.syncProviderBaseUrl} - active={provider.active} - title={provider.name} - /> + {providers.map((provider, idx) => ( + <BackupLayout + key={idx} + status={provider.paymentStatus} + timestamp={provider.lastSuccessfulBackupTimestamp} + id={provider.syncProviderBaseUrl} + active={provider.active} + title={provider.name} + /> + ))} + {!providers.length && ( + <Centered style={{ marginTop: 100 }}> + <BoldLight>No backup providers configured</BoldLight> + <ButtonSuccess onClick={onAddProvider}> + <i18n.Translate>Add provider</i18n.Translate> + </ButtonSuccess> + </Centered> )} - {!providers.length && <Centered style={{marginTop: 100}}> - <BoldLight>No backup providers configured</BoldLight> - <ButtonSuccess onClick={onAddProvider}><i18n.Translate>Add provider</i18n.Translate></ButtonSuccess> - </Centered>} </section> - {!!providers.length && <footer> - <div /> - <div> - <ButtonPrimary onClick={onSyncAll}>{ - providers.length > 1 ? - <i18n.Translate>Sync all backups</i18n.Translate> : - <i18n.Translate>Sync now</i18n.Translate> - }</ButtonPrimary> - <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess> - </div> - </footer>} + {!!providers.length && ( + <footer> + <div /> + <div> + <ButtonPrimary onClick={onSyncAll}> + {providers.length > 1 ? ( + <i18n.Translate>Sync all backups</i18n.Translate> + ) : ( + <i18n.Translate>Sync now</i18n.Translate> + )} + </ButtonPrimary> + <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess> + </div> + </footer> + )} </PopupBox> - ) + ); } interface TransactionLayoutProps { @@ -85,62 +118,80 @@ interface TransactionLayoutProps { active: boolean; } -function BackupLayout(props: TransactionLayoutProps): JSX.Element { +function BackupLayout(props: TransactionLayoutProps): VNode { const date = !props.timestamp ? undefined : new Date(props.timestamp.t_ms); const dateStr = date?.toLocaleString([], { dateStyle: "medium", timeStyle: "short", } as any); - return ( <RowBorderGray> <div style={{ color: !props.active ? "grey" : undefined }}> - <a href={Pages.provider_detail.replace(':pid', encodeURIComponent(props.id))}><span>{props.title}</span></a> - - {dateStr && <SmallText style={{marginTop: 5}}>Last synced: {dateStr}</SmallText>} - {!dateStr && <SmallLightText style={{marginTop: 5}}>Not synced</SmallLightText>} + <a + href={Pages.provider_detail.replace( + ":pid", + encodeURIComponent(props.id), + )} + > + <span>{props.title}</span> + </a> + + {dateStr && ( + <SmallText style={{ marginTop: 5 }}>Last synced: {dateStr}</SmallText> + )} + {!dateStr && ( + <SmallLightText style={{ marginTop: 5 }}>Not synced</SmallLightText> + )} </div> <div> - {props.status?.type === 'paid' ? - <ExpirationText until={props.status.paidUntil} /> : + {props.status?.type === "paid" ? ( + <ExpirationText until={props.status.paidUntil} /> + ) : ( <div>{props.status.type}</div> - } + )} </div> </RowBorderGray> ); } function ExpirationText({ until }: { until: Timestamp }) { - return <Fragment> - <CenteredText> Expires in </CenteredText> - <CenteredBoldText {...({ color: colorByTimeToExpire(until) })}> {daysUntil(until)} </CenteredBoldText> - </Fragment> + return ( + <Fragment> + <CenteredText> Expires in </CenteredText> + <CenteredBoldText {...{ color: colorByTimeToExpire(until) }}> + {" "} + {daysUntil(until)}{" "} + </CenteredBoldText> + </Fragment> + ); } function colorByTimeToExpire(d: Timestamp) { - if (d.t_ms === 'never') return 'rgb(28, 184, 65)' - const months = differenceInMonths(d.t_ms, new Date()) - return months > 1 ? 'rgb(28, 184, 65)' : 'rgb(223, 117, 20)'; + if (d.t_ms === "never") return "rgb(28, 184, 65)"; + const months = differenceInMonths(d.t_ms, new Date()); + return months > 1 ? "rgb(28, 184, 65)" : "rgb(223, 117, 20)"; } function daysUntil(d: Timestamp) { - if (d.t_ms === 'never') return undefined + if (d.t_ms === "never") return undefined; const duration = intervalToDuration({ start: d.t_ms, end: new Date(), - }) + }); const str = formatDuration(duration, { - delimiter: ', ', + delimiter: ", ", format: [ - duration?.years ? 'years' : ( - duration?.months ? 'months' : ( - duration?.days ? 'days' : ( - duration.hours ? 'hours' : 'minutes' - ) - ) - ) - ] - }) - return `${str}` -}
\ No newline at end of file + duration?.years + ? "years" + : duration?.months + ? "months" + : duration?.days + ? "days" + : duration.hours + ? "hours" + : "minutes", + ], + }); + return `${str}`; +} diff --git a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx index 382f9b549..80203f6d3 100644 --- a/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Balance.stories.tsx @@ -15,28 +15,25 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample, NullLink } from '../test-utils'; -import { BalanceView as TestedComponent } from './BalancePage'; +import { createExample, NullLink } from "../test-utils"; +import { BalanceView as TestedComponent } from "./BalancePage"; export default { - title: 'popup/balance', + title: "popup/balance", component: TestedComponent, - argTypes: { - } + argTypes: {}, }; - -export const NotYetLoaded = createExample(TestedComponent, { -}); +export const NotYetLoaded = createExample(TestedComponent, {}); export const GotError = createExample(TestedComponent, { balance: { hasError: true, - message: 'Network error' + message: "Network error", }, Linker: NullLink, }); @@ -45,7 +42,7 @@ export const EmptyBalance = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [] + balances: [], }, }, Linker: NullLink, @@ -55,13 +52,15 @@ export const SomeCoins = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:10.5', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:10.5", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -71,13 +70,15 @@ export const SomeCoinsAndIncomingMoney = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2.23', - hasPendingTransactions: false, - pendingIncoming: 'USD:5.11', - pendingOutgoing: 'USD:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2.23", + hasPendingTransactions: false, + pendingIncoming: "USD:5.11", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -87,13 +88,15 @@ export const SomeCoinsAndOutgoingMoney = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2.23', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:5.11', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2.23", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:5.11", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -103,13 +106,15 @@ export const SomeCoinsAndMovingMoney = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2.23', - hasPendingTransactions: false, - pendingIncoming: 'USD:2', - pendingOutgoing: 'USD:5.11', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2.23", + hasPendingTransactions: false, + pendingIncoming: "USD:2", + pendingOutgoing: "USD:5.11", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -119,19 +124,22 @@ export const SomeCoinsInTwoCurrencies = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2', - hasPendingTransactions: false, - pendingIncoming: 'USD:5.1', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'EUR:4', - hasPendingTransactions: false, - pendingIncoming: 'EUR:0', - pendingOutgoing: 'EUR:3.01', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2", + hasPendingTransactions: false, + pendingIncoming: "USD:5.1", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "EUR:4", + hasPendingTransactions: false, + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:3.01", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -141,78 +149,89 @@ export const SomeCoinsInTreeCurrencies = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:1', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'COL:2000', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'EUR:4', - hasPendingTransactions: false, - pendingIncoming: 'EUR:15', - pendingOutgoing: 'EUR:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:1", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "COL:2000", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "EUR:4", + hasPendingTransactions: false, + pendingIncoming: "EUR:15", + pendingOutgoing: "EUR:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, }); - export const SomeCoinsInFiveCurrencies = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:13451', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'EUR:202.02', - hasPendingTransactions: false, - pendingIncoming: 'EUR:0', - pendingOutgoing: 'EUR:0', - requiresUserInput: false - },{ - available: 'ARS:30', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'JPY:51223233', - hasPendingTransactions: false, - pendingIncoming: 'EUR:0', - pendingOutgoing: 'EUR:0', - requiresUserInput: false - },{ - available: 'JPY:51223233', - hasPendingTransactions: false, - pendingIncoming: 'EUR:0', - pendingOutgoing: 'EUR:0', - requiresUserInput: false - },{ - available: 'DEMOKUDOS:6', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'TESTKUDOS:6', - hasPendingTransactions: false, - pendingIncoming: 'USD:5', - pendingOutgoing: 'USD:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:13451", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "EUR:202.02", + hasPendingTransactions: false, + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:0", + requiresUserInput: false, + }, + { + available: "ARS:30", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "JPY:51223233", + hasPendingTransactions: false, + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:0", + requiresUserInput: false, + }, + { + available: "JPY:51223233", + hasPendingTransactions: false, + pendingIncoming: "EUR:0", + pendingOutgoing: "EUR:0", + requiresUserInput: false, + }, + { + available: "DEMOKUDOS:6", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "TESTKUDOS:6", + hasPendingTransactions: false, + pendingIncoming: "USD:5", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, diff --git a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx index 8e5c5c42e..a23c81cd1 100644 --- a/packages/taler-wallet-webextension/src/popup/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/popup/BalancePage.tsx @@ -15,20 +15,34 @@ */ import { - amountFractionalBase, Amounts, - Balance, BalancesResponse, - i18n + amountFractionalBase, + Amounts, + Balance, + i18n, } from "@gnu-taler/taler-util"; -import { JSX, h, Fragment } from "preact"; -import { ErrorMessage } from "../components/ErrorMessage"; -import { PopupBox, Centered, ButtonPrimary, ErrorBox, Middle } from "../components/styled/index"; +import { h, VNode } from "preact"; +import { + ButtonPrimary, + ErrorBox, + Middle, + PopupBox, +} from "../components/styled/index"; import { BalancesHook, useBalances } from "../hooks/useBalances"; import { PageLink, renderAmount } from "../renderHtml"; - -export function BalancePage({ goToWalletManualWithdraw }: { goToWalletManualWithdraw: () => void }) { - const balance = useBalances() - return <BalanceView balance={balance} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} /> +export function BalancePage({ + goToWalletManualWithdraw, +}: { + goToWalletManualWithdraw: () => void; +}): VNode { + const balance = useBalances(); + return ( + <BalanceView + balance={balance} + Linker={PageLink} + goToWalletManualWithdraw={goToWalletManualWithdraw} + /> + ); } export interface BalanceViewProps { balance: BalancesHook; @@ -36,32 +50,36 @@ export interface BalanceViewProps { goToWalletManualWithdraw: () => void; } -function formatPending(entry: Balance): JSX.Element { - let incoming: JSX.Element | undefined; - let payment: JSX.Element | undefined; +function formatPending(entry: Balance): VNode { + let incoming: VNode | undefined; + let payment: VNode | undefined; - const available = Amounts.parseOrThrow(entry.available); + // const available = Amounts.parseOrThrow(entry.available); const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming); const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing); if (!Amounts.isZero(pendingIncoming)) { incoming = ( - <span><i18n.Translate> - <span style={{ color: "darkgreen" }} title="incoming amount"> - {"+"} - {renderAmount(entry.pendingIncoming)} - </span>{" "} - </i18n.Translate></span> + <span> + <i18n.Translate> + <span style={{ color: "darkgreen" }} title="incoming amount"> + {"+"} + {renderAmount(entry.pendingIncoming)} + </span>{" "} + </i18n.Translate> + </span> ); } if (!Amounts.isZero(pendingOutgoing)) { payment = ( - <span><i18n.Translate> - <span style={{ color: "darkred" }} title="outgoing amount"> - {"-"} - {renderAmount(entry.pendingOutgoing)} - </span>{" "} - </i18n.Translate></span> + <span> + <i18n.Translate> + <span style={{ color: "darkred" }} title="outgoing amount"> + {"-"} + {renderAmount(entry.pendingOutgoing)} + </span>{" "} + </i18n.Translate> + </span> ); } @@ -80,76 +98,110 @@ function formatPending(entry: Balance): JSX.Element { ); } - -export function BalanceView({ balance, Linker, goToWalletManualWithdraw }: BalanceViewProps) { - - function Content() { +export function BalanceView({ + balance, + Linker, + goToWalletManualWithdraw, +}: BalanceViewProps): VNode { + function Content(): VNode { if (!balance) { - return <span /> + return <span />; } if (balance.hasError) { - return (<section> - <ErrorBox>{balance.message}</ErrorBox> - <p> - Click <Linker pageName="welcome">here</Linker> for help and - diagnostics. - </p> - </section>) + return ( + <section> + <ErrorBox>{balance.message}</ErrorBox> + <p> + Click <Linker pageName="welcome">here</Linker> for help and + diagnostics. + </p> + </section> + ); } if (balance.response.balances.length === 0) { - return (<section data-expanded> - <Middle> - <p><i18n.Translate> - You have no balance to show. Need some{" "} - <Linker pageName="/welcome">help</Linker> getting started? - </i18n.Translate></p> - </Middle> - </section>) + return ( + <section data-expanded> + <Middle> + <p> + <i18n.Translate> + You have no balance to show. Need some{" "} + <Linker pageName="/welcome">help</Linker> getting started? + </i18n.Translate> + </p> + </Middle> + </section> + ); } - return <section data-expanded data-centered> - <table style={{width:'100%'}}>{balance.response.balances.map((entry) => { - const av = Amounts.parseOrThrow(entry.available); - // Create our number formatter. - let formatter; - try { - formatter = new Intl.NumberFormat('en-US', { - style: 'currency', - currency: av.currency, - currencyDisplay: 'symbol' - // These options are needed to round to whole numbers if that's what you want. - //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1) - //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501) - }); - } catch { - formatter = new Intl.NumberFormat('en-US', { - // style: 'currency', - // currency: av.currency, - // These options are needed to round to whole numbers if that's what you want. - //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1) - //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501) - }); - } - - const v = formatter.format(av.value + av.fraction / amountFractionalBase); - const fontSize = v.length < 8 ? '3em' : (v.length < 13 ? '2em' : '1em') - return (<tr> - <td style={{ height: 50, fontSize, width: '60%', textAlign: 'right', padding: 0 }}>{v}</td> - <td style={{ maxWidth: '2em', overflowX: 'hidden' }}>{av.currency}</td> - <td style={{ fontSize: 'small', color: 'gray' }}>{formatPending(entry)}</td> - </tr> - ); - })}</table> - </section> + return ( + <section data-expanded data-centered> + <table style={{ width: "100%" }}> + {balance.response.balances.map((entry, idx) => { + const av = Amounts.parseOrThrow(entry.available); + // Create our number formatter. + let formatter; + try { + formatter = new Intl.NumberFormat("en-US", { + style: "currency", + currency: av.currency, + currencyDisplay: "symbol", + // These options are needed to round to whole numbers if that's what you want. + //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1) + //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501) + }); + } catch { + formatter = new Intl.NumberFormat("en-US", { + // style: 'currency', + // currency: av.currency, + // These options are needed to round to whole numbers if that's what you want. + //minimumFractionDigits: 0, // (this suffices for whole numbers, but will print 2500.10 as $2,500.1) + //maximumFractionDigits: 0, // (causes 2500.99 to be printed as $2,501) + }); + } + + const v = formatter.format( + av.value + av.fraction / amountFractionalBase, + ); + const fontSize = + v.length < 8 ? "3em" : v.length < 13 ? "2em" : "1em"; + return ( + <tr key={idx}> + <td + style={{ + height: 50, + fontSize, + width: "60%", + textAlign: "right", + padding: 0, + }} + > + {v} + </td> + <td style={{ maxWidth: "2em", overflowX: "hidden" }}> + {av.currency} + </td> + <td style={{ fontSize: "small", color: "gray" }}> + {formatPending(entry)} + </td> + </tr> + ); + })} + </table> + </section> + ); } - return <PopupBox> - {/* <section> */} - <Content /> - {/* </section> */} - <footer> - <div /> - <ButtonPrimary onClick={goToWalletManualWithdraw}>Withdraw</ButtonPrimary> - </footer> - </PopupBox> + return ( + <PopupBox> + {/* <section> */} + <Content /> + {/* </section> */} + <footer> + <div /> + <ButtonPrimary onClick={goToWalletManualWithdraw}> + Withdraw + </ButtonPrimary> + </footer> + </PopupBox> + ); } diff --git a/packages/taler-wallet-webextension/src/popup/Debug.tsx b/packages/taler-wallet-webextension/src/popup/Debug.tsx index ccc747466..b0e8543fc 100644 --- a/packages/taler-wallet-webextension/src/popup/Debug.tsx +++ b/packages/taler-wallet-webextension/src/popup/Debug.tsx @@ -14,18 +14,19 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { JSX, h } from "preact"; +import { h, VNode } from "preact"; import { Diagnostics } from "../components/Diagnostics"; import { useDiagnostics } from "../hooks/useDiagnostics.js"; import * as wxApi from "../wxApi"; - -export function DeveloperPage(props: any): JSX.Element { +export function DeveloperPage(): VNode { const [status, timedOut] = useDiagnostics(); return ( <div> <p>Debug tools:</p> - <button onClick={openExtensionPage("/static/popup.html")}>wallet tab</button> + <button onClick={openExtensionPage("/static/popup.html")}> + wallet tab + </button> <br /> <button onClick={confirmReset}>reset</button> <Diagnostics diagnostics={status} timedOut={timedOut} /> @@ -35,6 +36,7 @@ export function DeveloperPage(props: any): JSX.Element { export function reload(): void { try { + // eslint-disable-next-line no-undef chrome.runtime.reload(); window.close(); } catch (e) { @@ -46,7 +48,7 @@ export async function confirmReset(): Promise<void> { if ( confirm( "Do you want to IRREVOCABLY DESTROY everything inside your" + - " wallet and LOSE ALL YOUR COINS?", + " wallet and LOSE ALL YOUR COINS?", ) ) { await wxApi.resetDb(); @@ -56,9 +58,10 @@ export async function confirmReset(): Promise<void> { export function openExtensionPage(page: string) { return () => { + // eslint-disable-next-line no-undef chrome.tabs.create({ + // eslint-disable-next-line no-undef url: chrome.extension.getURL(page), }); }; } - diff --git a/packages/taler-wallet-webextension/src/popup/History.stories.tsx b/packages/taler-wallet-webextension/src/popup/History.stories.tsx index daa263a81..95f4a547a 100644 --- a/packages/taler-wallet-webextension/src/popup/History.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/History.stories.tsx @@ -15,135 +15,149 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { PaymentStatus, - TransactionCommon, TransactionDeposit, TransactionPayment, - TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, + TransactionCommon, + TransactionDeposit, + TransactionPayment, + TransactionRefresh, + TransactionRefund, + TransactionTip, + TransactionType, TransactionWithdrawal, - WithdrawalType -} from '@gnu-taler/taler-util'; -import { createExample } from '../test-utils'; -import { HistoryView as TestedComponent } from './History'; + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { createExample } from "../test-utils"; +import { HistoryView as TestedComponent } from "./History"; export default { - title: 'popup/history/list', + title: "popup/history/list", component: TestedComponent, }; const commonTransaction = { - amountRaw: 'USD:10', - amountEffective: 'USD:9', + amountRaw: "USD:10", + amountEffective: "USD:9", pending: false, timestamp: { - t_ms: new Date().getTime() + t_ms: new Date().getTime(), }, - transactionId: '12', -} as TransactionCommon + transactionId: "12", +} as TransactionCommon; const exampleData = { withdraw: { ...commonTransaction, type: TransactionType.Withdrawal, - exchangeBaseUrl: 'http://exchange.demo.taler.net', + exchangeBaseUrl: "http://exchange.demo.taler.net", withdrawalDetails: { confirmed: false, - exchangePaytoUris: ['payto://x-taler-bank/bank/account'], + exchangePaytoUris: ["payto://x-taler-bank/bank/account"], type: WithdrawalType.ManualTransfer, - } + }, } as TransactionWithdrawal, payment: { ...commonTransaction, - amountEffective: 'USD:11', + amountEffective: "USD:11", type: TransactionType.Payment, info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'the merchant', + name: "the merchant", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], - summary: 'the summary', - fulfillmentMessage: '', + summary: "the summary", + fulfillmentMessage: "", }, - proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", status: PaymentStatus.Accepted, } as TransactionPayment, deposit: { ...commonTransaction, type: TransactionType.Deposit, - depositGroupId: '#groupId', - targetPaytoUri: 'payto://x-taler-bank/bank/account', + depositGroupId: "#groupId", + targetPaytoUri: "payto://x-taler-bank/bank/account", } as TransactionDeposit, refresh: { ...commonTransaction, type: TransactionType.Refresh, - exchangeBaseUrl: 'http://exchange.taler', + exchangeBaseUrl: "http://exchange.taler", } as TransactionRefresh, tip: { ...commonTransaction, type: TransactionType.Tip, - merchantBaseUrl: 'http://merchant.taler', + merchantBaseUrl: "http://merchant.taler", } as TransactionTip, refund: { ...commonTransaction, type: TransactionType.Refund, - refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + refundedTransactionId: + "payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'the merchant', + name: "the merchant", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], - summary: 'the summary', - fulfillmentMessage: '', + summary: "the summary", + fulfillmentMessage: "", }, } as TransactionRefund, -} +}; export const EmptyWithBalance = createExample(TestedComponent, { list: [], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const EmptyWithNoBalance = createExample(TestedComponent, { list: [], - balances: [] + balances: [], }); export const One = createExample(TestedComponent, { list: [exampleData.withdraw], - balances: [{ - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const OnePending = createExample(TestedComponent, { - list: [{ - ...exampleData.withdraw, - pending: true, - }], - balances: [{ - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + list: [ + { + ...exampleData.withdraw, + pending: true, + }, + ], + balances: [ + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const Several = createExample(TestedComponent, { @@ -157,13 +171,15 @@ export const Several = createExample(TestedComponent, { exampleData.tip, exampleData.deposit, ], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const SeveralWithTwoCurrencies = createExample(TestedComponent, { @@ -177,18 +193,20 @@ export const SeveralWithTwoCurrencies = createExample(TestedComponent, { exampleData.tip, exampleData.deposit, ], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }, { - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); - diff --git a/packages/taler-wallet-webextension/src/popup/History.tsx b/packages/taler-wallet-webextension/src/popup/History.tsx index 1447da9b0..2228271dc 100644 --- a/packages/taler-wallet-webextension/src/popup/History.tsx +++ b/packages/taler-wallet-webextension/src/popup/History.tsx @@ -14,21 +14,28 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountString, Balance, i18n, Transaction, TransactionsResponse } from "@gnu-taler/taler-util"; -import { h, JSX } from "preact"; +import { + AmountString, + Balance, + i18n, + Transaction, + TransactionsResponse, +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { PopupBox } from "../components/styled"; import { TransactionItem } from "../components/TransactionItem"; import { useBalances } from "../hooks/useBalances"; import * as wxApi from "../wxApi"; - -export function HistoryPage(props: any): JSX.Element { +export function HistoryPage(): VNode { const [transactions, setTransactions] = useState< TransactionsResponse | undefined >(undefined); - const balance = useBalances() - const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || []) + const balance = useBalances(); + const balanceWithoutError = balance?.hasError + ? [] + : balance?.response.balances || []; useEffect(() => { const fetchData = async (): Promise<void> => { @@ -42,46 +49,79 @@ export function HistoryPage(props: any): JSX.Element { return <div>Loading ...</div>; } - return <HistoryView balances={balanceWithoutError} list={[...transactions.transactions].reverse()} />; + return ( + <HistoryView + balances={balanceWithoutError} + list={[...transactions.transactions].reverse()} + /> + ); } function amountToString(c: AmountString) { - const idx = c.indexOf(':') - return `${c.substring(idx + 1)} ${c.substring(0, idx)}` + const idx = c.indexOf(":"); + return `${c.substring(idx + 1)} ${c.substring(0, idx)}`; } - - -export function HistoryView({ list, balances }: { list: Transaction[], balances: Balance[] }) { - const multiCurrency = balances.length > 1 - return <PopupBox noPadding> - {balances.length > 0 && <header> - {multiCurrency ? <div class="title"> - Balance: <ul style={{ margin: 0 }}> - {balances.map(b => <li>{b.available}</li>)} - </ul> - </div> : <div class="title"> - Balance: <span>{amountToString(balances[0].available)}</span> - </div>} - </header>} - {list.length === 0 ? <section data-expanded data-centered> - <p><i18n.Translate> - You have no history yet, here you will be able to check your last transactions. - </i18n.Translate></p> - </section> : - <section> - {list.slice(0, 3).map((tx, i) => ( - <TransactionItem key={i} tx={tx} multiCurrency={multiCurrency} /> - ))} - </section> - } - <footer style={{ justifyContent: 'space-around' }}> - {list.length > 0 && - <a target="_blank" - rel="noopener noreferrer" - style={{ color: 'darkgreen', textDecoration: 'none' }} - href={chrome.extension ? chrome.extension.getURL(`/static/wallet.html#/history`) : '#'}>VIEW MORE TRANSACTIONS</a> - } - </footer> - </PopupBox> +export function HistoryView({ + list, + balances, +}: { + list: Transaction[]; + balances: Balance[]; +}) { + const multiCurrency = balances.length > 1; + return ( + <PopupBox noPadding> + {balances.length > 0 && ( + <header> + {multiCurrency ? ( + <div class="title"> + Balance:{" "} + <ul style={{ margin: 0 }}> + {balances.map((b) => ( + <li>{b.available}</li> + ))} + </ul> + </div> + ) : ( + <div class="title"> + Balance: <span>{amountToString(balances[0].available)}</span> + </div> + )} + </header> + )} + {list.length === 0 ? ( + <section data-expanded data-centered> + <p> + <i18n.Translate> + You have no history yet, here you will be able to check your last + transactions. + </i18n.Translate> + </p> + </section> + ) : ( + <section> + {list.slice(0, 3).map((tx, i) => ( + <TransactionItem key={i} tx={tx} multiCurrency={multiCurrency} /> + ))} + </section> + )} + <footer style={{ justifyContent: "space-around" }}> + {list.length > 0 && ( + <a + target="_blank" + rel="noopener noreferrer" + style={{ color: "darkgreen", textDecoration: "none" }} + href={ + chrome.extension + ? chrome.extension.getURL(`/static/wallet.html#/history`) + : "#" + } + > + VIEW MORE TRANSACTIONS + </a> + )} + </footer> + </PopupBox> + ); } diff --git a/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx b/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx index cd443e9d4..5009684c5 100644 --- a/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Popup.stories.tsx @@ -15,30 +15,29 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { NavBar as TestedComponent } from '../NavigationBar'; +import { createExample } from "../test-utils"; +import { NavBar as TestedComponent } from "../NavigationBar"; export default { - title: 'popup/header', + title: "popup/header", // component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const OnBalance = createExample(TestedComponent, { - devMode:false, - path:'/balance' + devMode: false, + path: "/balance", }); export const OnHistoryWithDevMode = createExample(TestedComponent, { - devMode:true, - path:'/history' + devMode: true, + path: "/history", }); diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx index de1f67b96..0cff7f75f 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderAddConfirmProvider.stories.tsx @@ -15,38 +15,37 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { ConfirmProviderView as TestedComponent } from './ProviderAddPage'; +import { createExample } from "../test-utils"; +import { ConfirmProviderView as TestedComponent } from "./ProviderAddPage"; export default { - title: 'popup/backup/confirm', + title: "popup/backup/confirm", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const DemoService = createExample(TestedComponent, { - url: 'https://sync.demo.taler.net/', + url: "https://sync.demo.taler.net/", provider: { - annual_fee: 'KUDOS:0.1', - storage_limit_in_megabytes: 20, - supported_protocol_version: '1' - } + annual_fee: "KUDOS:0.1", + storage_limit_in_megabytes: 20, + supported_protocol_version: "1", + }, }); export const FreeService = createExample(TestedComponent, { - url: 'https://sync.taler:9667/', + url: "https://sync.taler:9667/", provider: { - annual_fee: 'ARS:0', - storage_limit_in_megabytes: 20, - supported_protocol_version: '1' - } + annual_fee: "ARS:0", + storage_limit_in_megabytes: 20, + supported_protocol_version: "1", + }, }); diff --git a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx index 2daf49e0c..9a2f97051 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderAddSetUrl.stories.tsx @@ -15,39 +15,37 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { SetUrlView as TestedComponent } from './ProviderAddPage'; +import { createExample } from "../test-utils"; +import { SetUrlView as TestedComponent } from "./ProviderAddPage"; export default { - title: 'popup/backup/add', + title: "popup/backup/add", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - -export const Initial = createExample(TestedComponent, { -}); +export const Initial = createExample(TestedComponent, {}); export const WithValue = createExample(TestedComponent, { - initialValue: 'sync.demo.taler.net' -}); + initialValue: "sync.demo.taler.net", +}); export const WithConnectionError = createExample(TestedComponent, { - withError: 'Network error' -}); + withError: "Network error", +}); export const WithClientError = createExample(TestedComponent, { - withError: 'URL may not be right: (404) Not Found' -}); + withError: "URL may not be right: (404) Not Found", +}); export const WithServerError = createExample(TestedComponent, { - withError: 'Try another server: (500) Internal Server Error' -}); + withError: "Try another server: (500) Internal Server Error", +}); diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx index 4416608f8..fab21398a 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderDetail.stories.tsx @@ -15,224 +15,221 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core'; -import { createExample } from '../test-utils'; -import { ProviderView as TestedComponent } from './ProviderDetailPage'; +import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { createExample } from "../test-utils"; +import { ProviderView as TestedComponent } from "./ProviderDetailPage"; export default { - title: 'popup/backup/details', + title: "popup/backup/details", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const Active = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveErrorSync = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, }, lastAttemptedBackupTimestamp: { - "t_ms": 1625063925078 + t_ms: 1625063925078, }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, lastError: { code: 2002, - details: 'details', - hint: 'error hint from the server', - message: 'message' - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + details: "details", + hint: "error hint from the server", + message: "message", + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveBackupProblemUnreadable = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, backupProblem: { - type: 'backup-unreadable' - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + type: "backup-unreadable", + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveBackupProblemDevice = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, backupProblem: { - type: 'backup-conflicting-device', - myDeviceId: 'my-device-id', - otherDeviceId: 'other-device-id', + type: "backup-conflicting-device", + myDeviceId: "my-device-id", + otherDeviceId: "other-device-id", backupTimestamp: { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactiveUnpaid = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactiveInsufficientBalance = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.InsufficientBalance, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.InsufficientBalance, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactivePending = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Pending, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Pending, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); - export const ActiveTermsChanged = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.TermsChanged, + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.TermsChanged, paidUntil: { - t_ms: 1656599921000 + t_ms: 1656599921000, }, newTerms: { - "annualFee": "EUR:10", - "storageLimitInMegabytes": 8, - "supportedProtocolVersion": "0.0" + annualFee: "EUR:10", + storageLimitInMegabytes: 8, + supportedProtocolVersion: "0.0", }, oldTerms: { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); - diff --git a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx index 04adbb21c..9617c9a41 100644 --- a/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx +++ b/packages/taler-wallet-webextension/src/popup/ProviderDetailPage.tsx @@ -14,13 +14,23 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { i18n, Timestamp } from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { + ProviderInfo, + ProviderPaymentStatus, + ProviderPaymentType, +} from "@gnu-taler/taler-wallet-core"; import { format, formatDuration, intervalToDuration } from "date-fns"; import { Fragment, VNode, h } from "preact"; import { ErrorMessage } from "../components/ErrorMessage"; -import { Button, ButtonDestructive, ButtonPrimary, PaymentStatus, PopupBox, SmallLightText } from "../components/styled"; +import { + Button, + ButtonDestructive, + ButtonPrimary, + PaymentStatus, + PopupBox, + SmallLightText, +} from "../components/styled"; import { useProviderStatus } from "../hooks/useProviderStatus"; interface Props { @@ -29,20 +39,29 @@ interface Props { } export function ProviderDetailPage({ pid, onBack }: Props): VNode { - const status = useProviderStatus(pid) + const status = useProviderStatus(pid); if (!status) { - return <div><i18n.Translate>Loading...</i18n.Translate></div> + return ( + <div> + <i18n.Translate>Loading...</i18n.Translate> + </div> + ); } if (!status.info) { - onBack() - return <div /> + onBack(); + return <div />; } - return <ProviderView info={status.info} - onSync={status.sync} - onDelete={() => status.remove().then(onBack)} - onBack={onBack} - onExtend={() => { null }} - />; + return ( + <ProviderView + info={status.info} + onSync={status.sync} + onDelete={() => status.remove().then(onBack)} + onBack={onBack} + onExtend={() => { + null; + }} + /> + ); } export interface ViewProps { @@ -53,124 +72,185 @@ export interface ViewProps { onExtend: () => void; } -export function ProviderView({ info, onDelete, onSync, onBack, onExtend }: ViewProps): VNode { - const lb = info?.lastSuccessfulBackupTimestamp - const isPaid = info.paymentStatus.type === ProviderPaymentType.Paid || info.paymentStatus.type === ProviderPaymentType.TermsChanged +export function ProviderView({ + info, + onDelete, + onSync, + onBack, + onExtend, +}: ViewProps): VNode { + const lb = info?.lastSuccessfulBackupTimestamp; + const isPaid = + info.paymentStatus.type === ProviderPaymentType.Paid || + info.paymentStatus.type === ProviderPaymentType.TermsChanged; return ( <PopupBox> <Error info={info} /> <header> - <h3>{info.name} <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText></h3> - <PaymentStatus color={isPaid ? 'rgb(28, 184, 65)' : 'rgb(202, 60, 60)'}>{isPaid ? 'Paid' : 'Unpaid'}</PaymentStatus> + <h3> + {info.name}{" "} + <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText> + </h3> + <PaymentStatus color={isPaid ? "rgb(28, 184, 65)" : "rgb(202, 60, 60)"}> + {isPaid ? "Paid" : "Unpaid"} + </PaymentStatus> </header> <section> - <p><b>Last backup:</b> {lb == null || lb.t_ms == "never" ? "never" : format(lb.t_ms, 'dd MMM yyyy')} </p> - <ButtonPrimary onClick={onSync}><i18n.Translate>Back up</i18n.Translate></ButtonPrimary> - {info.terms && <Fragment> - <p><b>Provider fee:</b> {info.terms && info.terms.annualFee} per year</p> - </Fragment> - } + <p> + <b>Last backup:</b>{" "} + {lb == null || lb.t_ms == "never" + ? "never" + : format(lb.t_ms, "dd MMM yyyy")}{" "} + </p> + <ButtonPrimary onClick={onSync}> + <i18n.Translate>Back up</i18n.Translate> + </ButtonPrimary> + {info.terms && ( + <Fragment> + <p> + <b>Provider fee:</b> {info.terms && info.terms.annualFee} per year + </p> + </Fragment> + )} <p>{descriptionByStatus(info.paymentStatus)}</p> - <ButtonPrimary disabled onClick={onExtend}><i18n.Translate>Extend</i18n.Translate></ButtonPrimary> - - {info.paymentStatus.type === ProviderPaymentType.TermsChanged && <div> - <p><i18n.Translate>terms has changed, extending the service will imply accepting the new terms of service</i18n.Translate></p> - <table> - <thead> - <tr> - <td></td> - <td><i18n.Translate>old</i18n.Translate></td> - <td> -></td> - <td><i18n.Translate>new</i18n.Translate></td> - </tr> - </thead> - <tbody> - - <tr> - <td><i18n.Translate>fee</i18n.Translate></td> - <td>{info.paymentStatus.oldTerms.annualFee}</td> - <td>-></td> - <td>{info.paymentStatus.newTerms.annualFee}</td> - </tr> - <tr> - <td><i18n.Translate>storage</i18n.Translate></td> - <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td> - <td>-></td> - <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td> - </tr> - </tbody> - </table> - </div>} + <ButtonPrimary disabled onClick={onExtend}> + <i18n.Translate>Extend</i18n.Translate> + </ButtonPrimary> + {info.paymentStatus.type === ProviderPaymentType.TermsChanged && ( + <div> + <p> + <i18n.Translate> + terms has changed, extending the service will imply accepting + the new terms of service + </i18n.Translate> + </p> + <table> + <thead> + <tr> + <td></td> + <td> + <i18n.Translate>old</i18n.Translate> + </td> + <td> -></td> + <td> + <i18n.Translate>new</i18n.Translate> + </td> + </tr> + </thead> + <tbody> + <tr> + <td> + <i18n.Translate>fee</i18n.Translate> + </td> + <td>{info.paymentStatus.oldTerms.annualFee}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.annualFee}</td> + </tr> + <tr> + <td> + <i18n.Translate>storage</i18n.Translate> + </td> + <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td> + </tr> + </tbody> + </table> + </div> + )} </section> <footer> - <Button onClick={onBack}><i18n.Translate> < back</i18n.Translate></Button> + <Button onClick={onBack}> + <i18n.Translate> < back</i18n.Translate> + </Button> <div> - <ButtonDestructive onClick={onDelete}><i18n.Translate>remove provider</i18n.Translate></ButtonDestructive> + <ButtonDestructive onClick={onDelete}> + <i18n.Translate>remove provider</i18n.Translate> + </ButtonDestructive> </div> </footer> </PopupBox> - ) + ); } function daysSince(d?: Timestamp) { - if (!d || d.t_ms === 'never') return 'never synced' + if (!d || d.t_ms === "never") return "never synced"; const duration = intervalToDuration({ start: d.t_ms, end: new Date(), - }) + }); const str = formatDuration(duration, { - delimiter: ', ', + delimiter: ", ", format: [ - duration?.years ? i18n.str`years` : ( - duration?.months ? i18n.str`months` : ( - duration?.days ? i18n.str`days` : ( - duration?.hours ? i18n.str`hours` : ( - duration?.minutes ? i18n.str`minutes` : i18n.str`seconds` - ) - ) - ) - ) - ] - }) - return `synced ${str} ago` + duration?.years + ? i18n.str`years` + : duration?.months + ? i18n.str`months` + : duration?.days + ? i18n.str`days` + : duration?.hours + ? i18n.str`hours` + : duration?.minutes + ? i18n.str`minutes` + : i18n.str`seconds`, + ], + }); + return `synced ${str} ago`; } function Error({ info }: { info: ProviderInfo }) { if (info.lastError) { - return <ErrorMessage title={info.lastError.hint} /> + return <ErrorMessage title={info.lastError.hint} />; } if (info.backupProblem) { switch (info.backupProblem.type) { case "backup-conflicting-device": - return <ErrorMessage title={<Fragment> - <i18n.Translate>There is conflict with another backup from <b>{info.backupProblem.otherDeviceId}</b></i18n.Translate> - </Fragment>} /> + return ( + <ErrorMessage + title={ + <Fragment> + <i18n.Translate> + There is conflict with another backup from{" "} + <b>{info.backupProblem.otherDeviceId}</b> + </i18n.Translate> + </Fragment> + } + /> + ); case "backup-unreadable": - return <ErrorMessage title="Backup is not readable" /> + return <ErrorMessage title="Backup is not readable" />; default: - return <ErrorMessage title={<Fragment> - <i18n.Translate>Unknown backup problem: {JSON.stringify(info.backupProblem)}</i18n.Translate> - </Fragment>} /> + return ( + <ErrorMessage + title={ + <Fragment> + <i18n.Translate> + Unknown backup problem: {JSON.stringify(info.backupProblem)} + </i18n.Translate> + </Fragment> + } + /> + ); } } - return null + return null; } function colorByStatus(status: ProviderPaymentType) { switch (status) { case ProviderPaymentType.InsufficientBalance: - return 'rgb(223, 117, 20)' + return "rgb(223, 117, 20)"; case ProviderPaymentType.Unpaid: - return 'rgb(202, 60, 60)' + return "rgb(202, 60, 60)"; case ProviderPaymentType.Paid: - return 'rgb(28, 184, 65)' + return "rgb(28, 184, 65)"; case ProviderPaymentType.Pending: - return 'gray' + return "gray"; case ProviderPaymentType.InsufficientBalance: - return 'rgb(202, 60, 60)' + return "rgb(202, 60, 60)"; case ProviderPaymentType.TermsChanged: - return 'rgb(202, 60, 60)' + return "rgb(202, 60, 60)"; } } @@ -180,16 +260,19 @@ function descriptionByStatus(status: ProviderPaymentStatus) { // return i18n.str`not paid yet` case ProviderPaymentType.Paid: case ProviderPaymentType.TermsChanged: - if (status.paidUntil.t_ms === 'never') { - return i18n.str`service paid` + if (status.paidUntil.t_ms === "never") { + return i18n.str`service paid`; } else { - return <Fragment> - <b>Backup valid until:</b> {format(status.paidUntil.t_ms, 'dd MMM yyyy')} - </Fragment> + return ( + <Fragment> + <b>Backup valid until:</b>{" "} + {format(status.paidUntil.t_ms, "dd MMM yyyy")} + </Fragment> + ); } case ProviderPaymentType.Unpaid: case ProviderPaymentType.InsufficientBalance: case ProviderPaymentType.Pending: - return '' + return ""; } } diff --git a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx b/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx index 06e33c9d3..ae8e54ba1 100644 --- a/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/Settings.stories.tsx @@ -15,29 +15,28 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { SettingsView as TestedComponent } from './Settings'; +import { createExample } from "../test-utils"; +import { SettingsView as TestedComponent } from "./Settings"; export default { - title: 'popup/settings', + title: "popup/settings", component: TestedComponent, argTypes: { setDeviceName: () => Promise.resolve(), - } + }, }; export const AllOff = createExample(TestedComponent, { - deviceName: 'this-is-the-device-name', + deviceName: "this-is-the-device-name", setDeviceName: () => Promise.resolve(), }); export const OneChecked = createExample(TestedComponent, { - deviceName: 'this-is-the-device-name', + deviceName: "this-is-the-device-name", permissionsEnabled: true, setDeviceName: () => Promise.resolve(), }); - diff --git a/packages/taler-wallet-webextension/src/popup/Settings.tsx b/packages/taler-wallet-webextension/src/popup/Settings.tsx index 8595c87ff..3b83f0762 100644 --- a/packages/taler-wallet-webextension/src/popup/Settings.tsx +++ b/packages/taler-wallet-webextension/src/popup/Settings.tsx @@ -14,7 +14,6 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { i18n } from "@gnu-taler/taler-util"; import { VNode, h } from "preact"; import { Checkbox } from "../components/Checkbox"; @@ -28,15 +27,21 @@ import { useLang } from "../hooks/useLang"; export function SettingsPage(): VNode { const [permissionsEnabled, togglePermissions] = useExtendedPermissions(); - const { devMode, toggleDevMode } = useDevContext() - const { name, update } = useBackupDeviceName() - const [lang, changeLang] = useLang() - return <SettingsView - lang={lang} changeLang={changeLang} - deviceName={name} setDeviceName={update} - permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions} - developerMode={devMode} toggleDeveloperMode={toggleDevMode} - />; + const { devMode, toggleDevMode } = useDevContext(); + const { name, update } = useBackupDeviceName(); + const [lang, changeLang] = useLang(); + return ( + <SettingsView + lang={lang} + changeLang={changeLang} + deviceName={name} + setDeviceName={update} + permissionsEnabled={permissionsEnabled} + togglePermissions={togglePermissions} + developerMode={devMode} + toggleDeveloperMode={toggleDevMode} + /> + ); } export interface ViewProps { @@ -50,23 +55,31 @@ export interface ViewProps { toggleDeveloperMode: () => void; } -import { strings as messages } from '../i18n/strings' +import { strings as messages } from "../i18n/strings"; type LangsNames = { - [P in keyof typeof messages]: string -} + [P in keyof typeof messages]: string; +}; const names: LangsNames = { - es: 'Español [es]', - en: 'English [en]', - fr: 'Français [fr]', - de: 'Deutsch [de]', - sv: 'Svenska [sv]', - it: 'Italiano [it]', -} - + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; -export function SettingsView({ lang, changeLang, deviceName, setDeviceName, permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode }: ViewProps): VNode { +export function SettingsView({ + lang, + changeLang, + deviceName, + setDeviceName, + permissionsEnabled, + togglePermissions, + developerMode, + toggleDeveloperMode, +}: ViewProps): VNode { return ( <PopupBox> <section> @@ -86,25 +99,39 @@ export function SettingsView({ lang, changeLang, deviceName, setDeviceName, perm label={i18n.str`Device name`} description="(This is how you will recognize the wallet in the backup provider)" /> */} - <h2><i18n.Translate>Permissions</i18n.Translate></h2> - <Checkbox label="Automatically open wallet based on page content" + <h2> + <i18n.Translate>Permissions</i18n.Translate> + </h2> + <Checkbox + label="Automatically open wallet based on page content" name="perm" description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" - enabled={permissionsEnabled} onToggle={togglePermissions} + enabled={permissionsEnabled} + onToggle={togglePermissions} /> <h2>Config</h2> - <Checkbox label="Developer mode" + <Checkbox + label="Developer mode" name="devMode" description="(More options and information useful for debugging)" - enabled={developerMode} onToggle={toggleDeveloperMode} + enabled={developerMode} + onToggle={toggleDeveloperMode} /> </section> - <footer style={{ justifyContent: 'space-around' }}> - <a target="_blank" + <footer style={{ justifyContent: "space-around" }}> + <a + target="_blank" rel="noopener noreferrer" - style={{ color: 'darkgreen', textDecoration: 'none' }} - href={chrome.extension ? chrome.extension.getURL(`/static/wallet.html#/settings`) : '#'}>VIEW MORE SETTINGS</a> + style={{ color: "darkgreen", textDecoration: "none" }} + href={ + chrome.extension + ? chrome.extension.getURL(`/static/wallet.html#/settings`) + : "#" + } + > + VIEW MORE SETTINGS + </a> </footer> </PopupBox> - ) -}
\ No newline at end of file + ); +} diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx index 88c7c725e..f20403d6a 100644 --- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx +++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.stories.tsx @@ -15,38 +15,38 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { TalerActionFound as TestedComponent } from './TalerActionFound'; +import { createExample } from "../test-utils"; +import { TalerActionFound as TestedComponent } from "./TalerActionFound"; export default { - title: 'popup/TalerActionFound', + title: "popup/TalerActionFound", component: TestedComponent, }; export const PayAction = createExample(TestedComponent, { - url: 'taler://pay/something' + url: "taler://pay/something", }); export const WithdrawalAction = createExample(TestedComponent, { - url: 'taler://withdraw/something' + url: "taler://withdraw/something", }); export const TipAction = createExample(TestedComponent, { - url: 'taler://tip/something' + url: "taler://tip/something", }); export const NotifyAction = createExample(TestedComponent, { - url: 'taler://notify-reserve/something' + url: "taler://notify-reserve/something", }); export const RefundAction = createExample(TestedComponent, { - url: 'taler://refund/something' + url: "taler://refund/something", }); export const InvalidAction = createExample(TestedComponent, { - url: 'taler://something/asd' + url: "taler://something/asd", }); diff --git a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx index ef0ec341c..cbdcbeb15 100644 --- a/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx +++ b/packages/taler-wallet-webextension/src/popup/TalerActionFound.tsx @@ -1,5 +1,31 @@ +/* + 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 { classifyTalerUri, TalerUriType } from "@gnu-taler/taler-util"; -import { ButtonPrimary, ButtonSuccess, PopupBox } from "../components/styled/index"; +import { + ButtonPrimary, + ButtonSuccess, + PopupBox, +} from "../components/styled/index"; +import { h } from "preact"; export interface Props { url: string; @@ -8,54 +34,89 @@ export interface Props { export function TalerActionFound({ url, onDismiss }: Props) { const uriType = classifyTalerUri(url); - return <PopupBox> - <section> - <h1>Taler Action </h1> - {uriType === TalerUriType.TalerPay && <div> - <p>This page has pay action.</p> - <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}> - Open pay page - </ButtonSuccess> - </div>} - {uriType === TalerUriType.TalerWithdraw && <div> - <p>This page has a withdrawal action.</p> - <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}> - Open withdraw page - </ButtonSuccess> - </div>} - {uriType === TalerUriType.TalerTip && <div> - <p>This page has a tip action.</p> - <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}> - Open tip page - </ButtonSuccess> - </div>} - {uriType === TalerUriType.TalerNotifyReserve && <div> - <p>This page has a notify reserve action.</p> - <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}> - Notify - </ButtonSuccess> - </div>} - {uriType === TalerUriType.TalerRefund && <div> - <p>This page has a refund action.</p> - <ButtonSuccess onClick={() => { chrome.tabs.create({ "url": actionForTalerUri(uriType, url) }); }}> - Open refund page - </ButtonSuccess> - </div>} - {uriType === TalerUriType.Unknown && <div> - <p>This page has a malformed taler uri.</p> - <p>{url}</p> - </div>} - - </section> - <footer> - <div /> - <ButtonPrimary onClick={() => onDismiss()}> Dismiss </ButtonPrimary> - </footer> - </PopupBox>; - + return ( + <PopupBox> + <section> + <h1>Taler Action </h1> + {uriType === TalerUriType.TalerPay && ( + <div> + <p>This page has pay action.</p> + <ButtonSuccess + onClick={() => { + chrome.tabs.create({ url: actionForTalerUri(uriType, url) }); + }} + > + Open pay page + </ButtonSuccess> + </div> + )} + {uriType === TalerUriType.TalerWithdraw && ( + <div> + <p>This page has a withdrawal action.</p> + <ButtonSuccess + onClick={() => { + chrome.tabs.create({ url: actionForTalerUri(uriType, url) }); + }} + > + Open withdraw page + </ButtonSuccess> + </div> + )} + {uriType === TalerUriType.TalerTip && ( + <div> + <p>This page has a tip action.</p> + <ButtonSuccess + onClick={() => { + chrome.tabs.create({ url: actionForTalerUri(uriType, url) }); + }} + > + Open tip page + </ButtonSuccess> + </div> + )} + {uriType === TalerUriType.TalerNotifyReserve && ( + <div> + <p>This page has a notify reserve action.</p> + <ButtonSuccess + onClick={() => { + chrome.tabs.create({ url: actionForTalerUri(uriType, url) }); + }} + > + Notify + </ButtonSuccess> + </div> + )} + {uriType === TalerUriType.TalerRefund && ( + <div> + <p>This page has a refund action.</p> + <ButtonSuccess + onClick={() => { + chrome.tabs.create({ url: actionForTalerUri(uriType, url) }); + }} + > + Open refund page + </ButtonSuccess> + </div> + )} + {uriType === TalerUriType.Unknown && ( + <div> + <p>This page has a malformed taler uri.</p> + <p>{url}</p> + </div> + )} + </section> + <footer> + <div /> + <ButtonPrimary onClick={() => onDismiss()}> Dismiss </ButtonPrimary> + </footer> + </PopupBox> + ); } -function actionForTalerUri(uriType: TalerUriType, talerUri: string): string | undefined { +function actionForTalerUri( + uriType: TalerUriType, + talerUri: string, +): string | undefined { switch (uriType) { case TalerUriType.TalerWithdraw: return makeExtensionUrlWithParams("static/wallet.html#/withdraw", { @@ -91,8 +152,10 @@ function makeExtensionUrlWithParams( ): string { const innerUrl = new URL(chrome.extension.getURL("/" + url)); if (params) { - const hParams = Object.keys(params).map(k => `${k}=${params[k]}`).join('&') - innerUrl.hash = innerUrl.hash + '?' + hParams + const hParams = Object.keys(params) + .map((k) => `${k}=${params[k]}`) + .join("&"); + innerUrl.hash = innerUrl.hash + "?" + hParams; } return innerUrl.href; } diff --git a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx index 070df554c..a5723ccb5 100644 --- a/packages/taler-wallet-webextension/src/popupEntryPoint.tsx +++ b/packages/taler-wallet-webextension/src/popupEntryPoint.tsx @@ -22,19 +22,17 @@ import { setupI18n } from "@gnu-taler/taler-util"; import { createHashHistory } from "history"; -import { render, h, VNode } from "preact"; -import Router, { route, Route, getCurrentUrl } from "preact-router"; -import { useEffect, useState } from "preact/hooks"; +import { render, h } from "preact"; +import Router, { route, Route } from "preact-router"; +import { useEffect } from "preact/hooks"; import { DevContextProvider } from "./context/devContext"; import { useTalerActionURL } from "./hooks/useTalerActionURL"; import { strings } from "./i18n/strings"; +import { Pages, WalletNavBar } from "./NavigationBar"; import { BackupPage } from "./popup/BackupPage"; import { BalancePage } from "./popup/BalancePage"; -import { DeveloperPage as DeveloperPage } from "./popup/Debug"; +import { DeveloperPage } from "./popup/Debug"; import { HistoryPage } from "./popup/History"; -import { - Pages, WalletNavBar -} from "./NavigationBar"; import { ProviderAddPage } from "./popup/ProviderAddPage"; import { ProviderDetailPage } from "./popup/ProviderDetailPage"; import { SettingsPage } from "./popup/Settings"; @@ -64,11 +62,11 @@ if (document.readyState === "loading") { } function Application() { - const [talerActionUrl, setDismissed] = useTalerActionURL() + const [talerActionUrl, setDismissed] = useTalerActionURL(); useEffect(() => { - if (talerActionUrl) route(Pages.cta) - },[talerActionUrl]) + if (talerActionUrl) route(Pages.cta); + }, [talerActionUrl]); return ( <div> @@ -78,33 +76,54 @@ function Application() { <Router history={createHashHistory()}> <Route path={Pages.dev} component={DeveloperPage} /> - <Route path={Pages.balance} component={BalancePage} - goToWalletManualWithdraw={() => goToWalletPage(Pages.manual_withdraw)} + <Route + path={Pages.balance} + component={BalancePage} + goToWalletManualWithdraw={() => + goToWalletPage(Pages.manual_withdraw) + } /> <Route path={Pages.settings} component={SettingsPage} /> - <Route path={Pages.cta} component={() => <TalerActionFound url={talerActionUrl!} onDismiss={() => { - setDismissed(true) - route(Pages.balance) - }} />} /> + <Route + path={Pages.cta} + component={() => ( + <TalerActionFound + url={talerActionUrl!} + onDismiss={() => { + setDismissed(true); + route(Pages.balance); + }} + /> + )} + /> - <Route path={Pages.transaction} - component={({ tid }: { tid: string }) => goToWalletPage(Pages.transaction.replace(':tid', tid))} + <Route + path={Pages.transaction} + component={({ tid }: { tid: string }) => + goToWalletPage(Pages.transaction.replace(":tid", tid)) + } /> <Route path={Pages.history} component={HistoryPage} /> - <Route path={Pages.backup} component={BackupPage} + <Route + path={Pages.backup} + component={BackupPage} onAddProvider={() => { - route(Pages.provider_add) + route(Pages.provider_add); }} /> - <Route path={Pages.provider_detail} component={ProviderDetailPage} + <Route + path={Pages.provider_detail} + component={ProviderDetailPage} onBack={() => { - route(Pages.backup) + route(Pages.backup); }} /> - <Route path={Pages.provider_add} component={ProviderAddPage} + <Route + path={Pages.provider_add} + component={ProviderAddPage} onBack={() => { - route(Pages.backup) + route(Pages.backup); }} /> <Route default component={Redirect} to={Pages.balance} /> @@ -119,13 +138,13 @@ function goToWalletPage(page: Pages | string): null { chrome.tabs.create({ active: true, url: chrome.extension.getURL(`/static/wallet.html#${page}`), - }) - return null + }); + return null; } function Redirect({ to }: { to: string }): null { useEffect(() => { - route(to, true) - }) - return null + route(to, true); + }); + return null; } diff --git a/packages/taler-wallet-webextension/src/renderHtml.tsx b/packages/taler-wallet-webextension/src/renderHtml.tsx index bbe8e465c..15986d5d1 100644 --- a/packages/taler-wallet-webextension/src/renderHtml.tsx +++ b/packages/taler-wallet-webextension/src/renderHtml.tsx @@ -28,13 +28,13 @@ import { Amounts, amountFractionalBase, } from "@gnu-taler/taler-util"; -import { Component, ComponentChildren, JSX, h } from "preact"; +import { Component, ComponentChildren, h, VNode } from "preact"; /** * Render amount as HTML, which non-breaking space between * decimal value and currency. */ -export function renderAmount(amount: AmountJson | string): JSX.Element { +export function renderAmount(amount: AmountJson | string): VNode { let a; if (typeof amount === "string") { a = Amounts.parse(amount); @@ -56,13 +56,13 @@ export const AmountView = ({ amount, }: { amount: AmountJson | string; -}): JSX.Element => renderAmount(amount); +}): VNode => renderAmount(amount); /** * Abbreviate a string to a given length, and show the full * string on hover as a tooltip. */ -export function abbrev(s: string, n = 5): JSX.Element { +export function abbrev(s: string, n = 5): VNode { let sAbbrev = s; if (s.length > n) { sAbbrev = s.slice(0, n) + ".."; @@ -87,15 +87,12 @@ interface CollapsibleProps { * Component that shows/hides its children when clicking * a heading. */ -export class Collapsible extends Component< - CollapsibleProps, - CollapsibleState -> { +export class Collapsible extends Component<CollapsibleProps, CollapsibleState> { constructor(props: CollapsibleProps) { super(props); this.state = { collapsed: props.initiallyCollapsed }; } - render(): JSX.Element { + render(): VNode { const doOpen = (e: any): void => { this.setState({ collapsed: false }); e.preventDefault(); @@ -135,27 +132,24 @@ interface ExpanderTextProps { /** * Show a heading with a toggle to show/hide the expandable content. */ -export function ExpanderText({ text }: ExpanderTextProps): JSX.Element { +export function ExpanderText({ text }: ExpanderTextProps): VNode { return <span>{text}</span>; } -export interface LoadingButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> { +export interface LoadingButtonProps + extends h.JSX.HTMLAttributes<HTMLButtonElement> { isLoading: boolean; } -export function ProgressButton({isLoading, ...rest}: LoadingButtonProps): JSX.Element { +export function ProgressButton({ + isLoading, + ...rest +}: LoadingButtonProps): VNode { return ( - <button - class="pure-button pure-button-primary" - type="button" - {...rest} - > + <button class="pure-button pure-button-primary" type="button" {...rest}> {isLoading ? ( <span> - <object - class="svg-icon svg-baseline" - data="/img/spinner-bars.svg" - /> + <object class="svg-icon svg-baseline" data="/img/spinner-bars.svg" /> </span> ) : null}{" "} {rest.children} @@ -163,17 +157,14 @@ export function ProgressButton({isLoading, ...rest}: LoadingButtonProps): JSX.El ); } -export function PageLink( - props: { pageName: string, children?: ComponentChildren }, -): JSX.Element { +export function PageLink(props: { + pageName: string; + children?: ComponentChildren; +}): VNode { + // eslint-disable-next-line no-undef const url = chrome.extension.getURL(`/static/wallet.html#/${props.pageName}`); return ( - <a - class="actionLink" - href={url} - target="_blank" - rel="noopener noreferrer" - > + <a class="actionLink" href={url} target="_blank" rel="noopener noreferrer"> {props.children} </a> ); diff --git a/packages/taler-wallet-webextension/src/test-utils.ts b/packages/taler-wallet-webextension/src/test-utils.ts index 6bf1be3ff..28622bb85 100644 --- a/packages/taler-wallet-webextension/src/test-utils.ts +++ b/packages/taler-wallet-webextension/src/test-utils.ts @@ -14,15 +14,17 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { ComponentChildren, FunctionalComponent, h as render } from 'preact'; +import { ComponentChildren, FunctionalComponent, h as render } from "preact"; -export function createExample<Props>(Component: FunctionalComponent<Props>, props: Partial<Props>) { - const r = (args: any) => render(Component, args) - r.args = props - return r +export function createExample<Props>( + Component: FunctionalComponent<Props>, + props: Partial<Props>, +) { + const r = (args: any) => render(Component, args); + r.args = props; + return r; } - export function NullLink({ children }: { children?: ComponentChildren }) { - return render('a', { children, href: 'javascript:void(0);' }) + return render("a", { children, href: "javascript:void(0);" }); } diff --git a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx index 9a53fefe2..b2771bc2a 100644 --- a/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Backup.stories.tsx @@ -15,179 +15,184 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core'; -import { addDays } from 'date-fns'; -import { BackupView as TestedComponent } from './BackupPage'; -import { createExample } from '../test-utils'; +import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { addDays } from "date-fns"; +import { BackupView as TestedComponent } from "./BackupPage"; +import { createExample } from "../test-utils"; export default { - title: 'wallet/backup/list', + title: "wallet/backup/list", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const LotOfProviders = createExample(TestedComponent, { - providers: [{ - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 + providers: [ + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": addDays(new Date(), 13).getTime() - } + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: addDays(new Date(), 13).getTime(), + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Pending, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Pending, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.InsufficientBalance, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.InsufficientBalance, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.TermsChanged, - newTerms: { - annualFee: 'USD:2', - storageLimitInMegabytes: 8, - supportedProtocolVersion: '2', - }, - oldTerms: { - annualFee: 'USD:1', + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.TermsChanged, + newTerms: { + annualFee: "USD:2", + storageLimitInMegabytes: 8, + supportedProtocolVersion: "2", + }, + oldTerms: { + annualFee: "USD:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "1", + }, + paidUntil: { + t_ms: "never", + }, + }, + terms: { + annualFee: "KUDOS:0.1", storageLimitInMegabytes: 16, - supportedProtocolVersion: '1', - + supportedProtocolVersion: "0.0", }, - paidUntil: { - t_ms: 'never' - } }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, + { + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "KUDOS:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "KUDOS:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }] + ], }); - export const OneProvider = createExample(TestedComponent, { - providers: [{ - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" - ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + providers: [ + { + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", + ], + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "ARS:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, }, - "terms": { - "annualFee": "ARS:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }] + ], }); - export const Empty = createExample(TestedComponent, { - providers: [] + providers: [], }); - diff --git a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx index 712329bf8..f0ae38e0f 100644 --- a/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/BackupPage.tsx @@ -14,15 +14,28 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { i18n, Timestamp } from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentStatus } from "@gnu-taler/taler-wallet-core"; -import { differenceInMonths, formatDuration, intervalToDuration } from "date-fns"; -import { Fragment, JSX, VNode, h } from "preact"; import { - BoldLight, ButtonPrimary, ButtonSuccess, Centered, - CenteredText, CenteredBoldText, PopupBox, RowBorderGray, - SmallText, SmallLightText, WalletBox + ProviderInfo, + ProviderPaymentStatus, +} from "@gnu-taler/taler-wallet-core"; +import { + differenceInMonths, + formatDuration, + intervalToDuration, +} from "date-fns"; +import { Fragment, h, VNode } from "preact"; +import { + BoldLight, + ButtonPrimary, + ButtonSuccess, + Centered, + CenteredBoldText, + CenteredText, + RowBorderGray, + SmallLightText, + SmallText, + WalletBox, } from "../components/styled"; import { useBackupStatus } from "../hooks/useBackupStatus"; import { Pages } from "../NavigationBar"; @@ -32,49 +45,69 @@ interface Props { } export function BackupPage({ onAddProvider }: Props): VNode { - const status = useBackupStatus() + const status = useBackupStatus(); if (!status) { - return <div>Loading...</div> + return <div>Loading...</div>; } - return <BackupView providers={status.providers} onAddProvider={onAddProvider} onSyncAll={status.sync} />; + return ( + <BackupView + providers={status.providers} + onAddProvider={onAddProvider} + onSyncAll={status.sync} + /> + ); } export interface ViewProps { - providers: ProviderInfo[], + providers: ProviderInfo[]; onAddProvider: () => void; onSyncAll: () => Promise<void>; } -export function BackupView({ providers, onAddProvider, onSyncAll }: ViewProps): VNode { +export function BackupView({ + providers, + onAddProvider, + onSyncAll, +}: ViewProps): VNode { return ( <WalletBox> <section> - {providers.map((provider) => <BackupLayout - status={provider.paymentStatus} - timestamp={provider.lastSuccessfulBackupTimestamp} - id={provider.syncProviderBaseUrl} - active={provider.active} - title={provider.name} - /> + {providers.map((provider, idx) => ( + <BackupLayout + key={idx} + status={provider.paymentStatus} + timestamp={provider.lastSuccessfulBackupTimestamp} + id={provider.syncProviderBaseUrl} + active={provider.active} + title={provider.name} + /> + ))} + {!providers.length && ( + <Centered style={{ marginTop: 100 }}> + <BoldLight>No backup providers configured</BoldLight> + <ButtonSuccess onClick={onAddProvider}> + <i18n.Translate>Add provider</i18n.Translate> + </ButtonSuccess> + </Centered> )} - {!providers.length && <Centered style={{ marginTop: 100 }}> - <BoldLight>No backup providers configured</BoldLight> - <ButtonSuccess onClick={onAddProvider}><i18n.Translate>Add provider</i18n.Translate></ButtonSuccess> - </Centered>} </section> - {!!providers.length && <footer> - <div /> - <div> - <ButtonPrimary onClick={onSyncAll}>{ - providers.length > 1 ? - <i18n.Translate>Sync all backups</i18n.Translate> : - <i18n.Translate>Sync now</i18n.Translate> - }</ButtonPrimary> - <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess> - </div> - </footer>} + {!!providers.length && ( + <footer> + <div /> + <div> + <ButtonPrimary onClick={onSyncAll}> + {providers.length > 1 ? ( + <i18n.Translate>Sync all backups</i18n.Translate> + ) : ( + <i18n.Translate>Sync now</i18n.Translate> + )} + </ButtonPrimary> + <ButtonSuccess onClick={onAddProvider}>Add provider</ButtonSuccess> + </div> + </footer> + )} </WalletBox> - ) + ); } interface TransactionLayoutProps { @@ -85,62 +118,80 @@ interface TransactionLayoutProps { active: boolean; } -function BackupLayout(props: TransactionLayoutProps): JSX.Element { +function BackupLayout(props: TransactionLayoutProps): VNode { const date = !props.timestamp ? undefined : new Date(props.timestamp.t_ms); const dateStr = date?.toLocaleString([], { dateStyle: "medium", timeStyle: "short", } as any); - return ( <RowBorderGray> <div style={{ color: !props.active ? "grey" : undefined }}> - <a href={Pages.provider_detail.replace(':pid', encodeURIComponent(props.id))}><span>{props.title}</span></a> - - {dateStr && <SmallText style={{ marginTop: 5 }}>Last synced: {dateStr}</SmallText>} - {!dateStr && <SmallLightText style={{ marginTop: 5 }}>Not synced</SmallLightText>} + <a + href={Pages.provider_detail.replace( + ":pid", + encodeURIComponent(props.id), + )} + > + <span>{props.title}</span> + </a> + + {dateStr && ( + <SmallText style={{ marginTop: 5 }}>Last synced: {dateStr}</SmallText> + )} + {!dateStr && ( + <SmallLightText style={{ marginTop: 5 }}>Not synced</SmallLightText> + )} </div> <div> - {props.status?.type === 'paid' ? - <ExpirationText until={props.status.paidUntil} /> : + {props.status?.type === "paid" ? ( + <ExpirationText until={props.status.paidUntil} /> + ) : ( <div>{props.status.type}</div> - } + )} </div> </RowBorderGray> ); } function ExpirationText({ until }: { until: Timestamp }) { - return <Fragment> - <CenteredText> Expires in </CenteredText> - <CenteredBoldText {...({ color: colorByTimeToExpire(until) })}> {daysUntil(until)} </CenteredBoldText> - </Fragment> + return ( + <Fragment> + <CenteredText> Expires in </CenteredText> + <CenteredBoldText {...{ color: colorByTimeToExpire(until) }}> + {" "} + {daysUntil(until)}{" "} + </CenteredBoldText> + </Fragment> + ); } function colorByTimeToExpire(d: Timestamp) { - if (d.t_ms === 'never') return 'rgb(28, 184, 65)' - const months = differenceInMonths(d.t_ms, new Date()) - return months > 1 ? 'rgb(28, 184, 65)' : 'rgb(223, 117, 20)'; + if (d.t_ms === "never") return "rgb(28, 184, 65)"; + const months = differenceInMonths(d.t_ms, new Date()); + return months > 1 ? "rgb(28, 184, 65)" : "rgb(223, 117, 20)"; } function daysUntil(d: Timestamp) { - if (d.t_ms === 'never') return undefined + if (d.t_ms === "never") return undefined; const duration = intervalToDuration({ start: d.t_ms, end: new Date(), - }) + }); const str = formatDuration(duration, { - delimiter: ', ', + delimiter: ", ", format: [ - duration?.years ? 'years' : ( - duration?.months ? 'months' : ( - duration?.days ? 'days' : ( - duration.hours ? 'hours' : 'minutes' - ) - ) - ) - ] - }) - return `${str}` -}
\ No newline at end of file + duration?.years + ? "years" + : duration?.months + ? "months" + : duration?.days + ? "days" + : duration.hours + ? "hours" + : "minutes", + ], + }); + return `${str}`; +} diff --git a/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx index cccda203e..2432c31eb 100644 --- a/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Balance.stories.tsx @@ -15,28 +15,25 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample, NullLink } from '../test-utils'; -import { BalanceView as TestedComponent } from './BalancePage'; +import { createExample, NullLink } from "../test-utils"; +import { BalanceView as TestedComponent } from "./BalancePage"; export default { - title: 'wallet/balance', + title: "wallet/balance", component: TestedComponent, - argTypes: { - } + argTypes: {}, }; - -export const NotYetLoaded = createExample(TestedComponent, { -}); +export const NotYetLoaded = createExample(TestedComponent, {}); export const GotError = createExample(TestedComponent, { balance: { hasError: true, - message: 'Network error' + message: "Network error", }, Linker: NullLink, }); @@ -45,7 +42,7 @@ export const EmptyBalance = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [] + balances: [], }, }, Linker: NullLink, @@ -55,13 +52,15 @@ export const SomeCoins = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:10.5', - hasPendingTransactions: false, - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:10.5", + hasPendingTransactions: false, + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -71,13 +70,15 @@ export const SomeCoinsAndIncomingMoney = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2.23', - hasPendingTransactions: false, - pendingIncoming: 'USD:5.11', - pendingOutgoing: 'USD:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2.23", + hasPendingTransactions: false, + pendingIncoming: "USD:5.11", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, @@ -87,19 +88,22 @@ export const SomeCoinsInTwoCurrencies = createExample(TestedComponent, { balance: { hasError: false, response: { - balances: [{ - available: 'USD:2', - hasPendingTransactions: false, - pendingIncoming: 'USD:5', - pendingOutgoing: 'USD:0', - requiresUserInput: false - },{ - available: 'EUR:4', - hasPendingTransactions: false, - pendingIncoming: 'EUR:5', - pendingOutgoing: 'EUR:0', - requiresUserInput: false - }] + balances: [ + { + available: "USD:2", + hasPendingTransactions: false, + pendingIncoming: "USD:5", + pendingOutgoing: "USD:0", + requiresUserInput: false, + }, + { + available: "EUR:4", + hasPendingTransactions: false, + pendingIncoming: "EUR:5", + pendingOutgoing: "EUR:0", + requiresUserInput: false, + }, + ], }, }, Linker: NullLink, diff --git a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx b/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx index eb5a0447c..9a2847670 100644 --- a/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/BalancePage.tsx @@ -15,19 +15,30 @@ */ import { - amountFractionalBase, Amounts, - Balance, BalancesResponse, - i18n + amountFractionalBase, + Amounts, + Balance, + BalancesResponse, + i18n, } from "@gnu-taler/taler-util"; -import { JSX } from "preact"; +import { h, VNode } from "preact"; import { ButtonPrimary, Centered, WalletBox } from "../components/styled/index"; import { BalancesHook, useBalances } from "../hooks/useBalances"; import { PageLink, renderAmount } from "../renderHtml"; - -export function BalancePage({ goToWalletManualWithdraw }: { goToWalletManualWithdraw: () => void }) { - const balance = useBalances() - return <BalanceView balance={balance} Linker={PageLink} goToWalletManualWithdraw={goToWalletManualWithdraw} /> +export function BalancePage({ + goToWalletManualWithdraw, +}: { + goToWalletManualWithdraw: () => void; +}): VNode { + const balance = useBalances(); + return ( + <BalanceView + balance={balance} + Linker={PageLink} + goToWalletManualWithdraw={goToWalletManualWithdraw} + /> + ); } export interface BalanceViewProps { @@ -36,9 +47,13 @@ export interface BalanceViewProps { goToWalletManualWithdraw: () => void; } -export function BalanceView({ balance, Linker, goToWalletManualWithdraw }: BalanceViewProps) { +export function BalanceView({ + balance, + Linker, + goToWalletManualWithdraw, +}: BalanceViewProps): VNode { if (!balance) { - return <span /> + return <span />; } if (balance.hasError) { @@ -50,38 +65,45 @@ export function BalanceView({ balance, Linker, goToWalletManualWithdraw }: Balan diagnostics. </p> </div> - ) + ); } if (balance.response.balances.length === 0) { return ( - <p><i18n.Translate> - You have no balance to show. Need some{" "} - <Linker pageName="/welcome">help</Linker> getting started? - </i18n.Translate></p> - ) + <p> + <i18n.Translate> + You have no balance to show. Need some{" "} + <Linker pageName="/welcome">help</Linker> getting started? + </i18n.Translate> + </p> + ); } - return <ShowBalances wallet={balance.response} - onWithdraw={goToWalletManualWithdraw} - /> + return ( + <ShowBalances + wallet={balance.response} + onWithdraw={goToWalletManualWithdraw} + /> + ); } -function formatPending(entry: Balance): JSX.Element { - let incoming: JSX.Element | undefined; - let payment: JSX.Element | undefined; +function formatPending(entry: Balance): VNode { + let incoming: VNode | undefined; + let payment: VNode | undefined; - const available = Amounts.parseOrThrow(entry.available); + // const available = Amounts.parseOrThrow(entry.available); const pendingIncoming = Amounts.parseOrThrow(entry.pendingIncoming); - const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing); + // const pendingOutgoing = Amounts.parseOrThrow(entry.pendingOutgoing); if (!Amounts.isZero(pendingIncoming)) { incoming = ( - <span><i18n.Translate> - <span style={{ color: "darkgreen" }}> - {"+"} - {renderAmount(entry.pendingIncoming)} - </span>{" "} - incoming - </i18n.Translate></span> + <span> + <i18n.Translate> + <span style={{ color: "darkgreen" }}> + {"+"} + {renderAmount(entry.pendingIncoming)} + </span>{" "} + incoming + </i18n.Translate> + </span> ); } @@ -100,27 +122,36 @@ function formatPending(entry: Balance): JSX.Element { ); } - -function ShowBalances({ wallet, onWithdraw }: { wallet: BalancesResponse, onWithdraw: () => void }) { - return <WalletBox> - <section> - <Centered>{wallet.balances.map((entry) => { - const av = Amounts.parseOrThrow(entry.available); - const v = av.value + av.fraction / amountFractionalBase; - return ( - <p key={av.currency}> - <span> - <span style={{ fontSize: "5em", display: "block" }}>{v}</span>{" "} - <span>{av.currency}</span> - </span> - {formatPending(entry)} - </p> - ); - })}</Centered> - </section> - <footer> - <div /> - <ButtonPrimary onClick={onWithdraw} >Withdraw</ButtonPrimary> - </footer> - </WalletBox> +function ShowBalances({ + wallet, + onWithdraw, +}: { + wallet: BalancesResponse; + onWithdraw: () => void; +}): VNode { + return ( + <WalletBox> + <section> + <Centered> + {wallet.balances.map((entry) => { + const av = Amounts.parseOrThrow(entry.available); + const v = av.value + av.fraction / amountFractionalBase; + return ( + <p key={av.currency}> + <span> + <span style={{ fontSize: "5em", display: "block" }}>{v}</span>{" "} + <span>{av.currency}</span> + </span> + {formatPending(entry)} + </p> + ); + })} + </Centered> + </section> + <footer> + <div /> + <ButtonPrimary onClick={onWithdraw}>Withdraw</ButtonPrimary> + </footer> + </WalletBox> + ); } diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx index 35da52392..300e9cd57 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.stories.tsx @@ -15,42 +15,40 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { CreateManualWithdraw as TestedComponent } from './CreateManualWithdraw'; +import { createExample } from "../test-utils"; +import { CreateManualWithdraw as TestedComponent } from "./CreateManualWithdraw"; export default { - title: 'wallet/manual withdraw/creation', + title: "wallet/manual withdraw/creation", component: TestedComponent, - argTypes: { - } + argTypes: {}, }; +// , +const exchangeList = { + "http://exchange.taler:8081": "COL", + "http://exchange.tal": "EUR", +}; export const InitialState = createExample(TestedComponent, { + exchangeList, }); -export const WithExchangeFilled = createExample(TestedComponent, { - currency: 'COL', - initialExchange: 'http://exchange.taler:8081', -}); - -export const WithExchangeAndAmountFilled = createExample(TestedComponent, { - currency: 'COL', - initialExchange: 'http://exchange.taler:8081', - initialAmount: '10' +export const WithAmountInitialized = createExample(TestedComponent, { + initialAmount: "10", + exchangeList, }); export const WithExchangeError = createExample(TestedComponent, { - initialExchange: 'http://exchange.tal', - error: 'The exchange url seems invalid' + error: "The exchange url seems invalid", + exchangeList, }); export const WithAmountError = createExample(TestedComponent, { - currency: 'COL', - initialExchange: 'http://exchange.taler:8081', - initialAmount: 'e' + initialAmount: "e", + exchangeList, }); diff --git a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx index be2cbe41d..140ac2d40 100644 --- a/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx +++ b/packages/taler-wallet-webextension/src/wallet/CreateManualWithdraw.tsx @@ -1,56 +1,149 @@ +/* + 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 { AmountJson, Amounts } from "@gnu-taler/taler-util"; -import { VNode } from "preact"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { h, VNode } from "preact"; +import { useState } from "preact/hooks"; import { ErrorMessage } from "../components/ErrorMessage"; -import { ButtonPrimary, Input, InputWithLabel, LightText, WalletBox } from "../components/styled"; +import { SelectList } from "../components/SelectList"; +import { + ButtonPrimary, + Input, + InputWithLabel, + LightText, + WalletBox, +} from "../components/styled"; export interface Props { error: string | undefined; - currency: string | undefined; - initialExchange?: string; initialAmount?: string; - onExchangeChange: (exchange: string) => void; + exchangeList: Record<string, string>; onCreate: (exchangeBaseUrl: string, amount: AmountJson) => Promise<void>; } -export function CreateManualWithdraw({ onExchangeChange, initialExchange, initialAmount, error, currency, onCreate }: Props): VNode { +export function CreateManualWithdraw({ + initialAmount, + exchangeList, + error, + onCreate, +}: Props): VNode { + const exchangeSelectList = Object.keys(exchangeList); + const currencySelectList = Object.values(exchangeList); + const exchangeMap = exchangeSelectList.reduce( + (p, c) => ({ ...p, [c]: `${c} (${exchangeList[c]})` }), + {} as Record<string, string>, + ); + const currencyMap = currencySelectList.reduce( + (p, c) => ({ ...p, [c]: c }), + {} as Record<string, string>, + ); + + const initialExchange = + exchangeSelectList.length > 0 ? exchangeSelectList[0] : ""; + const [exchange, setExchange] = useState(initialExchange || ""); + const [currency, setCurrency] = useState(exchangeList[initialExchange] ?? ""); + const [amount, setAmount] = useState(initialAmount || ""); - const parsedAmount = Amounts.parse(`${currency}:${amount}`) + const parsedAmount = Amounts.parse(`${currency}:${amount}`); - let timeout = useRef<number | undefined>(undefined); - useEffect(() => { - if (timeout) window.clearTimeout(timeout.current) - timeout.current = window.setTimeout(async () => { - onExchangeChange(exchange) - }, 1000); - }, [exchange]) + function changeExchange(exchange: string): void { + setExchange(exchange); + setCurrency(exchangeList[exchange]); + } + function changeCurrency(currency: string): void { + setCurrency(currency); + const found = Object.entries(exchangeList).find((e) => e[1] === currency); + + if (found) { + setExchange(found[0]); + } else { + setExchange(""); + } + } + + if (!initialExchange) { + return <div>There is no known exchange where to withdraw, add one</div>; + } return ( <WalletBox> <section> - <ErrorMessage title={error && "Can't create the reserve"} description={error} /> + <ErrorMessage + title={error && "Can't create the reserve"} + description={error} + /> <h2>Manual Withdrawal</h2> - <LightText>Choose a exchange to create a reserve and then fill the reserve to withdraw the coins</LightText> + <LightText> + Choose a exchange to create a reserve and then fill the reserve to + withdraw the coins + </LightText> <p> - <Input invalid={!!exchange && !currency}> - <label>Exchange</label> - <input type="text" placeholder="https://" value={exchange} onChange={(e) => setExchange(e.currentTarget.value)} /> - <small>http://exchange.taler:8081</small> + <Input> + <SelectList + label="Currency" + list={currencyMap} + name="currency" + value={currency} + onChange={changeCurrency} + /> + </Input> + <Input> + <SelectList + label="Exchange" + list={exchangeMap} + name="currency" + value={exchange} + onChange={changeExchange} + /> </Input> - {currency && <InputWithLabel invalid={!!amount && !parsedAmount}> - <label>Amount</label> - <div> - <div>{currency}</div> - <input type="number" style={{ paddingLeft: `${currency.length}em` }} value={amount} onChange={e => setAmount(e.currentTarget.value)} /> - </div> - </InputWithLabel>} + {/* <p style={{ display: "flex", justifyContent: "right" }}> + <a href="" style={{ marginLeft: "auto" }}> + Add new exchange + </a> + </p> */} + {currency && ( + <InputWithLabel invalid={!!amount && !parsedAmount}> + <label>Amount</label> + <div> + <span>{currency}</span> + <input + type="number" + value={amount} + onInput={(e) => setAmount(e.currentTarget.value)} + /> + </div> + </InputWithLabel> + )} </p> </section> <footer> <div /> - <ButtonPrimary disabled={!parsedAmount || !exchange} onClick={() => onCreate(exchange, parsedAmount!)}>Create</ButtonPrimary> + <ButtonPrimary + disabled={!parsedAmount || !exchange} + onClick={() => onCreate(exchange, parsedAmount!)} + > + Start withdrawal + </ButtonPrimary> </footer> </WalletBox> ); diff --git a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx index 0ac4be9a6..9ae3ac3bd 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.stories.tsx @@ -15,133 +15,146 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { PaymentStatus, - TransactionCommon, TransactionDeposit, TransactionPayment, - TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, + TransactionCommon, + TransactionDeposit, + TransactionPayment, + TransactionRefresh, + TransactionRefund, + TransactionTip, + TransactionType, TransactionWithdrawal, - WithdrawalType -} from '@gnu-taler/taler-util'; -import { HistoryView as TestedComponent } from './History'; -import { createExample } from '../test-utils'; - + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { HistoryView as TestedComponent } from "./History"; +import { createExample } from "../test-utils"; export default { - title: 'wallet/history/list', + title: "wallet/history/list", component: TestedComponent, }; -let count = 0 -const commonTransaction = () => ({ - amountRaw: 'USD:10', - amountEffective: 'USD:9', - pending: false, - timestamp: { - t_ms: new Date().getTime() - (count++ * 1000 * 60 * 60 * 7) - }, - transactionId: '12', -} as TransactionCommon) +let count = 0; +const commonTransaction = () => + ({ + amountRaw: "USD:10", + amountEffective: "USD:9", + pending: false, + timestamp: { + t_ms: new Date().getTime() - count++ * 1000 * 60 * 60 * 7, + }, + transactionId: "12", + } as TransactionCommon); const exampleData = { withdraw: { ...commonTransaction(), type: TransactionType.Withdrawal, - exchangeBaseUrl: 'http://exchange.demo.taler.net', + exchangeBaseUrl: "http://exchange.demo.taler.net", withdrawalDetails: { confirmed: false, - exchangePaytoUris: ['payto://x-taler-bank/bank/account'], + exchangePaytoUris: ["payto://x-taler-bank/bank/account"], type: WithdrawalType.ManualTransfer, - } + }, } as TransactionWithdrawal, payment: { ...commonTransaction(), - amountEffective: 'USD:11', + amountEffective: "USD:11", type: TransactionType.Payment, info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'Blog', + name: "Blog", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], - summary: 'the summary', - fulfillmentMessage: '', + summary: "the summary", + fulfillmentMessage: "", }, - proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", status: PaymentStatus.Accepted, } as TransactionPayment, deposit: { ...commonTransaction(), type: TransactionType.Deposit, - depositGroupId: '#groupId', - targetPaytoUri: 'payto://x-taler-bank/bank/account', + depositGroupId: "#groupId", + targetPaytoUri: "payto://x-taler-bank/bank/account", } as TransactionDeposit, refresh: { ...commonTransaction(), type: TransactionType.Refresh, - exchangeBaseUrl: 'http://exchange.taler', + exchangeBaseUrl: "http://exchange.taler", } as TransactionRefresh, tip: { ...commonTransaction(), type: TransactionType.Tip, - merchantBaseUrl: 'http://ads.merchant.taler.net/', + merchantBaseUrl: "http://ads.merchant.taler.net/", } as TransactionTip, refund: { ...commonTransaction(), type: TransactionType.Refund, - refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + refundedTransactionId: + "payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'the merchant', + name: "the merchant", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], - summary: 'the summary', - fulfillmentMessage: '', + summary: "the summary", + fulfillmentMessage: "", }, } as TransactionRefund, -} +}; export const Empty = createExample(TestedComponent, { list: [], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); - export const One = createExample(TestedComponent, { list: [exampleData.withdraw], - balances: [{ - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const OnePending = createExample(TestedComponent, { - list: [{ - ...exampleData.withdraw, - pending: true - }], - balances: [{ - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + list: [ + { + ...exampleData.withdraw, + pending: true, + }, + ], + balances: [ + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const Several = createExample(TestedComponent, { @@ -154,20 +167,23 @@ export const Several = createExample(TestedComponent, { ...exampleData.payment, info: { ...exampleData.payment.info, - summary: 'this is a long summary that may be cropped because its too long', + summary: + "this is a long summary that may be cropped because its too long", }, }, exampleData.refund, exampleData.tip, exampleData.deposit, ], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); export const SeveralWithTwoCurrencies = createExample(TestedComponent, { @@ -181,18 +197,20 @@ export const SeveralWithTwoCurrencies = createExample(TestedComponent, { exampleData.tip, exampleData.deposit, ], - balances: [{ - available: 'TESTKUDOS:10', - pendingIncoming: 'TESTKUDOS:0', - pendingOutgoing: 'TESTKUDOS:0', - hasPendingTransactions: false, - requiresUserInput: false, - }, { - available: 'USD:10', - pendingIncoming: 'USD:0', - pendingOutgoing: 'USD:0', - hasPendingTransactions: false, - requiresUserInput: false, - }] + balances: [ + { + available: "TESTKUDOS:10", + pendingIncoming: "TESTKUDOS:0", + pendingOutgoing: "TESTKUDOS:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + { + available: "USD:10", + pendingIncoming: "USD:0", + pendingOutgoing: "USD:0", + hasPendingTransactions: false, + requiresUserInput: false, + }, + ], }); - diff --git a/packages/taler-wallet-webextension/src/wallet/History.tsx b/packages/taler-wallet-webextension/src/wallet/History.tsx index 8160f8574..6b1a21852 100644 --- a/packages/taler-wallet-webextension/src/wallet/History.tsx +++ b/packages/taler-wallet-webextension/src/wallet/History.tsx @@ -14,22 +14,28 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountString, Balance, Transaction, TransactionsResponse } from "@gnu-taler/taler-util"; -import { format } from "date-fns"; -import { Fragment, h, JSX } from "preact"; +import { + AmountString, + Balance, + Transaction, + TransactionsResponse, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; import { useEffect, useState } from "preact/hooks"; import { DateSeparator, WalletBox } from "../components/styled"; +import { Time } from "../components/Time"; import { TransactionItem } from "../components/TransactionItem"; import { useBalances } from "../hooks/useBalances"; import * as wxApi from "../wxApi"; - -export function HistoryPage(props: any): JSX.Element { +export function HistoryPage(): VNode { const [transactions, setTransactions] = useState< TransactionsResponse | undefined >(undefined); - const balance = useBalances() - const balanceWithoutError = balance?.hasError ? [] : (balance?.response.balances || []) + const balance = useBalances(); + const balanceWithoutError = balance?.hasError + ? [] + : balance?.response.balances || []; useEffect(() => { const fetchData = async (): Promise<void> => { @@ -43,45 +49,85 @@ export function HistoryPage(props: any): JSX.Element { return <div>Loading ...</div>; } - return <HistoryView balances={balanceWithoutError} list={[...transactions.transactions].reverse()} />; + return ( + <HistoryView + balances={balanceWithoutError} + list={[...transactions.transactions].reverse()} + /> + ); } -function amountToString(c: AmountString) { - const idx = c.indexOf(':') - return `${c.substring(idx + 1)} ${c.substring(0, idx)}` +function amountToString(c: AmountString): string { + const idx = c.indexOf(":"); + return `${c.substring(idx + 1)} ${c.substring(0, idx)}`; } +const term = 1000 * 60 * 60 * 24; +function normalizeToDay(x: number): number { + return Math.round(x / term) * term; +} +export function HistoryView({ + list, + balances, +}: { + list: Transaction[]; + balances: Balance[]; +}): VNode { + const byDate = list.reduce((rv, x) => { + const theDate = + x.timestamp.t_ms === "never" ? 0 : normalizeToDay(x.timestamp.t_ms); + if (theDate) { + (rv[theDate] = rv[theDate] || []).push(x); + } -export function HistoryView({ list, balances }: { list: Transaction[], balances: Balance[] }) { - const byDate = list.reduce(function (rv, x) { - const theDate = x.timestamp.t_ms === "never" ? "never" : format(x.timestamp.t_ms, 'dd MMMM yyyy'); - (rv[theDate] = rv[theDate] || []).push(x); return rv; }, {} as { [x: string]: Transaction[] }); - const multiCurrency = balances.length > 1 + const multiCurrency = balances.length > 1; - return <WalletBox noPadding> - {balances.length > 0 && <header> - {balances.length === 1 && <div class="title"> - Balance: <span>{amountToString(balances[0].available)}</span> - </div>} - {balances.length > 1 && <div class="title"> - Balance: <ul style={{ margin: 0 }}> - {balances.map(b => <li>{b.available}</li>)} - </ul> - </div>} - </header>} - <section> - {Object.keys(byDate).map((d,i) => { - return <Fragment key={i}> - <DateSeparator>{d}</DateSeparator> - {byDate[d].map((tx, i) => ( - <TransactionItem key={i} tx={tx} multiCurrency={multiCurrency}/> - ))} - </Fragment> - })} - </section> - </WalletBox> + return ( + <WalletBox noPadding> + {balances.length > 0 && ( + <header> + {balances.length === 1 && ( + <div class="title"> + Balance: <span>{amountToString(balances[0].available)}</span> + </div> + )} + {balances.length > 1 && ( + <div class="title"> + Balance:{" "} + <ul style={{ margin: 0 }}> + {balances.map((b, i) => ( + <li key={i}>{b.available}</li> + ))} + </ul> + </div> + )} + </header> + )} + <section> + {Object.keys(byDate).map((d, i) => { + return ( + <Fragment key={i}> + <DateSeparator> + <Time + timestamp={{ t_ms: Number.parseInt(d, 10) }} + format="dd MMMM yyyy" + /> + </DateSeparator> + {byDate[d].map((tx, i) => ( + <TransactionItem + key={i} + tx={tx} + multiCurrency={multiCurrency} + /> + ))} + </Fragment> + ); + })} + </section> + </WalletBox> + ); } diff --git a/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx b/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx index dcc0002e6..1af4e8d8d 100644 --- a/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ManualWithdrawPage.tsx @@ -14,68 +14,83 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - -import { VNode } from "preact"; -import { useEffect, useRef, useState } from "preact/hooks"; +import { VNode, h } from "preact"; +import { useState } from "preact/hooks"; import { CreateManualWithdraw } from "./CreateManualWithdraw"; -import * as wxApi from '../wxApi' -import { AcceptManualWithdrawalResult, AmountJson, Amounts } from "@gnu-taler/taler-util"; +import * as wxApi from "../wxApi"; +import { + AcceptManualWithdrawalResult, + AmountJson, + Amounts, +} from "@gnu-taler/taler-util"; import { ReserveCreated } from "./ReserveCreated.js"; -import { route } from 'preact-router'; +import { route } from "preact-router"; import { Pages } from "../NavigationBar.js"; +import { useAsyncAsHook } from "../hooks/useAsyncAsHook"; -interface Props { - -} - -export function ManualWithdrawPage({ }: Props): VNode { - const [success, setSuccess] = useState<AcceptManualWithdrawalResult | undefined>(undefined) - const [currency, setCurrency] = useState<string | undefined>(undefined) - const [error, setError] = useState<string | undefined>(undefined) - - async function onExchangeChange(exchange: string | undefined) { - if (!exchange) return - try { - const r = await fetch(`${exchange}/keys`) - const j = await r.json() - if (j.currency) { - await wxApi.addExchange({ - exchangeBaseUrl: `${exchange}/`, - forceUpdate: true - }) - setCurrency(j.currency) +export function ManualWithdrawPage(): VNode { + const [success, setSuccess] = useState< + | { + response: AcceptManualWithdrawalResult; + exchangeBaseUrl: string; + amount: AmountJson; } - } catch (e) { - setError('The exchange url seems invalid') - setCurrency(undefined) - } - } + | undefined + >(undefined); + const [error, setError] = useState<string | undefined>(undefined); + + const knownExchangesHook = useAsyncAsHook(() => wxApi.listExchanges()); - async function doCreate(exchangeBaseUrl: string, amount: AmountJson) { + async function doCreate( + exchangeBaseUrl: string, + amount: AmountJson, + ): Promise<void> { try { - const resp = await wxApi.acceptManualWithdrawal(exchangeBaseUrl, Amounts.stringify(amount)) - setSuccess(resp) + const response = await wxApi.acceptManualWithdrawal( + exchangeBaseUrl, + Amounts.stringify(amount), + ); + setSuccess({ exchangeBaseUrl, response, amount }); } catch (e) { if (e instanceof Error) { - setError(e.message) + setError(e.message); } else { - setError('unexpected error') + setError("unexpected error"); } - setSuccess(undefined) + setSuccess(undefined); } } if (success) { - return <ReserveCreated reservePub={success.reservePub} paytos={success.exchangePaytoUris} onBack={() => { - route(Pages.balance) - }}/> + return ( + <ReserveCreated + reservePub={success.response.reservePub} + payto={success.response.exchangePaytoUris[0]} + exchangeBaseUrl={success.exchangeBaseUrl} + amount={success.amount} + onBack={() => { + route(Pages.balance); + }} + /> + ); } - return <CreateManualWithdraw - error={error} currency={currency} - onCreate={doCreate} onExchangeChange={onExchangeChange} - />; -} - - + if (!knownExchangesHook || knownExchangesHook.hasError) { + return <div>No Known exchanges</div>; + } + const exchangeList = knownExchangesHook.response.exchanges.reduce( + (p, c) => ({ + ...p, + [c.exchangeBaseUrl]: c.currency, + }), + {} as Record<string, string>, + ); + return ( + <CreateManualWithdraw + error={error} + exchangeList={exchangeList} + onCreate={doCreate} + /> + ); +} diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx index d1e76c053..5c4e56b15 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddConfirmProvider.stories.tsx @@ -15,38 +15,37 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { ConfirmProviderView as TestedComponent } from './ProviderAddPage'; +import { createExample } from "../test-utils"; +import { ConfirmProviderView as TestedComponent } from "./ProviderAddPage"; export default { - title: 'wallet/backup/confirm', + title: "wallet/backup/confirm", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const DemoService = createExample(TestedComponent, { - url: 'https://sync.demo.taler.net/', + url: "https://sync.demo.taler.net/", provider: { - annual_fee: 'KUDOS:0.1', - storage_limit_in_megabytes: 20, - supported_protocol_version: '1' - } + annual_fee: "KUDOS:0.1", + storage_limit_in_megabytes: 20, + supported_protocol_version: "1", + }, }); export const FreeService = createExample(TestedComponent, { - url: 'https://sync.taler:9667/', + url: "https://sync.taler:9667/", provider: { - annual_fee: 'ARS:0', - storage_limit_in_megabytes: 20, - supported_protocol_version: '1' - } + annual_fee: "ARS:0", + storage_limit_in_megabytes: 20, + supported_protocol_version: "1", + }, }); diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx index 4890e5e9c..75292b7e4 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderAddSetUrl.stories.tsx @@ -15,39 +15,37 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { SetUrlView as TestedComponent } from './ProviderAddPage'; +import { createExample } from "../test-utils"; +import { SetUrlView as TestedComponent } from "./ProviderAddPage"; export default { - title: 'wallet/backup/add', + title: "wallet/backup/add", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - -export const Initial = createExample(TestedComponent, { -}); +export const Initial = createExample(TestedComponent, {}); export const WithValue = createExample(TestedComponent, { - initialValue: 'sync.demo.taler.net' -}); + initialValue: "sync.demo.taler.net", +}); export const WithConnectionError = createExample(TestedComponent, { - withError: 'Network error' -}); + withError: "Network error", +}); export const WithClientError = createExample(TestedComponent, { - withError: 'URL may not be right: (404) Not Found' -}); + withError: "URL may not be right: (404) Not Found", +}); export const WithServerError = createExample(TestedComponent, { - withError: 'Try another server: (500) Internal Server Error' -}); + withError: "Try another server: (500) Internal Server Error", +}); diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx index 67ff83442..a170620a3 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetail.stories.tsx @@ -15,224 +15,221 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { ProviderPaymentType } from '@gnu-taler/taler-wallet-core'; -import { createExample } from '../test-utils'; -import { ProviderView as TestedComponent } from './ProviderDetailPage'; +import { ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; +import { createExample } from "../test-utils"; +import { ProviderView as TestedComponent } from "./ProviderDetailPage"; export default { - title: 'wallet/backup/details', + title: "wallet/backup/details", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; - export const Active = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveErrorSync = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, }, lastAttemptedBackupTimestamp: { - "t_ms": 1625063925078 + t_ms: 1625063925078, }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, lastError: { code: 2002, - details: 'details', - hint: 'error hint from the server', - message: 'message' - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + details: "details", + hint: "error hint from the server", + message: "message", + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveBackupProblemUnreadable = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, backupProblem: { - type: 'backup-unreadable' - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + type: "backup-unreadable", + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const ActiveBackupProblemDevice = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.taler:9967/", - "lastSuccessfulBackupTimestamp": { - "t_ms": 1625063925078 - }, - "paymentProposalIds": [ - "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG" + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.taler:9967/", + lastSuccessfulBackupTimestamp: { + t_ms: 1625063925078, + }, + paymentProposalIds: [ + "43Q5WWRJPNS4SE9YKS54H9THDS94089EDGXW9EHBPN6E7M184XEG", ], - "paymentStatus": { - "type": ProviderPaymentType.Paid, - "paidUntil": { - "t_ms": 1656599921000 - } + paymentStatus: { + type: ProviderPaymentType.Paid, + paidUntil: { + t_ms: 1656599921000, + }, }, backupProblem: { - type: 'backup-conflicting-device', - myDeviceId: 'my-device-id', - otherDeviceId: 'other-device-id', + type: "backup-conflicting-device", + myDeviceId: "my-device-id", + otherDeviceId: "other-device-id", backupTimestamp: { - "t_ms": 1656599921000 - } - }, - "terms": { - "annualFee": "EUR:1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + t_ms: 1656599921000, + }, + }, + terms: { + annualFee: "EUR:1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactiveUnpaid = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Unpaid, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Unpaid, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactiveInsufficientBalance = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.InsufficientBalance, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.InsufficientBalance, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); export const InactivePending = createExample(TestedComponent, { info: { - "active": false, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.Pending, - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + active: false, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.Pending, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); - export const ActiveTermsChanged = createExample(TestedComponent, { info: { - "active": true, - name:'sync.demo', - "syncProviderBaseUrl": "http://sync.demo.taler.net/", - "paymentProposalIds": [], - "paymentStatus": { - "type": ProviderPaymentType.TermsChanged, + active: true, + name: "sync.demo", + syncProviderBaseUrl: "http://sync.demo.taler.net/", + paymentProposalIds: [], + paymentStatus: { + type: ProviderPaymentType.TermsChanged, paidUntil: { - t_ms: 1656599921000 + t_ms: 1656599921000, }, newTerms: { - "annualFee": "EUR:10", - "storageLimitInMegabytes": 8, - "supportedProtocolVersion": "0.0" + annualFee: "EUR:10", + storageLimitInMegabytes: 8, + supportedProtocolVersion: "0.0", }, oldTerms: { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - }, - "terms": { - "annualFee": "EUR:0.1", - "storageLimitInMegabytes": 16, - "supportedProtocolVersion": "0.0" - } - } + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, + terms: { + annualFee: "EUR:0.1", + storageLimitInMegabytes: 16, + supportedProtocolVersion: "0.0", + }, + }, }); - diff --git a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx index c45458eb7..1c14c6e0a 100644 --- a/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ProviderDetailPage.tsx @@ -14,13 +14,23 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - -import { i18n, Timestamp } from "@gnu-taler/taler-util"; -import { ProviderInfo, ProviderPaymentStatus, ProviderPaymentType } from "@gnu-taler/taler-wallet-core"; -import { format, formatDuration, intervalToDuration } from "date-fns"; -import { Fragment, VNode, h } from "preact"; +import { i18n } from "@gnu-taler/taler-util"; +import { + ProviderInfo, + ProviderPaymentStatus, + ProviderPaymentType, +} from "@gnu-taler/taler-wallet-core"; +import { Fragment, h, VNode } from "preact"; import { ErrorMessage } from "../components/ErrorMessage"; -import { Button, ButtonDestructive, ButtonPrimary, PaymentStatus, WalletBox, SmallLightText } from "../components/styled"; +import { + Button, + ButtonDestructive, + ButtonPrimary, + PaymentStatus, + SmallLightText, + WalletBox, +} from "../components/styled"; +import { Time } from "../components/Time"; import { useProviderStatus } from "../hooks/useProviderStatus"; interface Props { @@ -29,20 +39,29 @@ interface Props { } export function ProviderDetailPage({ pid, onBack }: Props): VNode { - const status = useProviderStatus(pid) + const status = useProviderStatus(pid); if (!status) { - return <div><i18n.Translate>Loading...</i18n.Translate></div> + return ( + <div> + <i18n.Translate>Loading...</i18n.Translate> + </div> + ); } if (!status.info) { - onBack() - return <div /> + onBack(); + return <div />; } - return <ProviderView info={status.info} - onSync={status.sync} - onDelete={() => status.remove().then(onBack)} - onBack={onBack} - onExtend={() => { null }} - />; + return ( + <ProviderView + info={status.info} + onSync={status.sync} + onDelete={() => status.remove().then(onBack)} + onBack={onBack} + onExtend={() => { + null; + }} + /> + ); } export interface ViewProps { @@ -53,143 +72,204 @@ export interface ViewProps { onExtend: () => void; } -export function ProviderView({ info, onDelete, onSync, onBack, onExtend }: ViewProps): VNode { - const lb = info?.lastSuccessfulBackupTimestamp - const isPaid = info.paymentStatus.type === ProviderPaymentType.Paid || info.paymentStatus.type === ProviderPaymentType.TermsChanged +export function ProviderView({ + info, + onDelete, + onSync, + onBack, + onExtend, +}: ViewProps): VNode { + const lb = info?.lastSuccessfulBackupTimestamp; + const isPaid = + info.paymentStatus.type === ProviderPaymentType.Paid || + info.paymentStatus.type === ProviderPaymentType.TermsChanged; return ( <WalletBox> <Error info={info} /> <header> - <h3>{info.name} <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText></h3> - <PaymentStatus color={isPaid ? 'rgb(28, 184, 65)' : 'rgb(202, 60, 60)'}>{isPaid ? 'Paid' : 'Unpaid'}</PaymentStatus> + <h3> + {info.name}{" "} + <SmallLightText>{info.syncProviderBaseUrl}</SmallLightText> + </h3> + <PaymentStatus color={isPaid ? "rgb(28, 184, 65)" : "rgb(202, 60, 60)"}> + {isPaid ? "Paid" : "Unpaid"} + </PaymentStatus> </header> <section> - <p><b>Last backup:</b> {lb == null || lb.t_ms == "never" ? "never" : format(lb.t_ms, 'dd MMM yyyy')} </p> - <ButtonPrimary onClick={onSync}><i18n.Translate>Back up</i18n.Translate></ButtonPrimary> - {info.terms && <Fragment> - <p><b>Provider fee:</b> {info.terms && info.terms.annualFee} per year</p> - </Fragment> - } + <p> + <b>Last backup:</b> <Time timestamp={lb} format="dd MMMM yyyy" /> + </p> + <ButtonPrimary onClick={onSync}> + <i18n.Translate>Back up</i18n.Translate> + </ButtonPrimary> + {info.terms && ( + <Fragment> + <p> + <b>Provider fee:</b> {info.terms && info.terms.annualFee} per year + </p> + </Fragment> + )} <p>{descriptionByStatus(info.paymentStatus)}</p> - <ButtonPrimary disabled onClick={onExtend}><i18n.Translate>Extend</i18n.Translate></ButtonPrimary> - - {info.paymentStatus.type === ProviderPaymentType.TermsChanged && <div> - <p><i18n.Translate>terms has changed, extending the service will imply accepting the new terms of service</i18n.Translate></p> - <table> - <thead> - <tr> - <td></td> - <td><i18n.Translate>old</i18n.Translate></td> - <td> -></td> - <td><i18n.Translate>new</i18n.Translate></td> - </tr> - </thead> - <tbody> - - <tr> - <td><i18n.Translate>fee</i18n.Translate></td> - <td>{info.paymentStatus.oldTerms.annualFee}</td> - <td>-></td> - <td>{info.paymentStatus.newTerms.annualFee}</td> - </tr> - <tr> - <td><i18n.Translate>storage</i18n.Translate></td> - <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td> - <td>-></td> - <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td> - </tr> - </tbody> - </table> - </div>} + <ButtonPrimary disabled onClick={onExtend}> + <i18n.Translate>Extend</i18n.Translate> + </ButtonPrimary> + {info.paymentStatus.type === ProviderPaymentType.TermsChanged && ( + <div> + <p> + <i18n.Translate> + terms has changed, extending the service will imply accepting + the new terms of service + </i18n.Translate> + </p> + <table> + <thead> + <tr> + <td> </td> + <td> + <i18n.Translate>old</i18n.Translate> + </td> + <td> -></td> + <td> + <i18n.Translate>new</i18n.Translate> + </td> + </tr> + </thead> + <tbody> + <tr> + <td> + <i18n.Translate>fee</i18n.Translate> + </td> + <td>{info.paymentStatus.oldTerms.annualFee}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.annualFee}</td> + </tr> + <tr> + <td> + <i18n.Translate>storage</i18n.Translate> + </td> + <td>{info.paymentStatus.oldTerms.storageLimitInMegabytes}</td> + <td>-></td> + <td>{info.paymentStatus.newTerms.storageLimitInMegabytes}</td> + </tr> + </tbody> + </table> + </div> + )} </section> <footer> - <Button onClick={onBack}><i18n.Translate> < back</i18n.Translate></Button> + <Button onClick={onBack}> + <i18n.Translate> < back</i18n.Translate> + </Button> <div> - <ButtonDestructive onClick={onDelete}><i18n.Translate>remove provider</i18n.Translate></ButtonDestructive> + <ButtonDestructive onClick={onDelete}> + <i18n.Translate>remove provider</i18n.Translate> + </ButtonDestructive> </div> </footer> </WalletBox> - ) + ); } -function daysSince(d?: Timestamp) { - if (!d || d.t_ms === 'never') return 'never synced' - const duration = intervalToDuration({ - start: d.t_ms, - end: new Date(), - }) - const str = formatDuration(duration, { - delimiter: ', ', - format: [ - duration?.years ? i18n.str`years` : ( - duration?.months ? i18n.str`months` : ( - duration?.days ? i18n.str`days` : ( - duration?.hours ? i18n.str`hours` : ( - duration?.minutes ? i18n.str`minutes` : i18n.str`seconds` - ) - ) - ) - ) - ] - }) - return `synced ${str} ago` -} +// function daysSince(d?: Timestamp): string { +// if (!d || d.t_ms === "never") return "never synced"; +// const duration = intervalToDuration({ +// start: d.t_ms, +// end: new Date(), +// }); +// const str = formatDuration(duration, { +// delimiter: ", ", +// format: [ +// duration?.years +// ? i18n.str`years` +// : duration?.months +// ? i18n.str`months` +// : duration?.days +// ? i18n.str`days` +// : duration?.hours +// ? i18n.str`hours` +// : duration?.minutes +// ? i18n.str`minutes` +// : i18n.str`seconds`, +// ], +// }); +// return `synced ${str} ago`; +// } -function Error({ info }: { info: ProviderInfo }) { +function Error({ info }: { info: ProviderInfo }): VNode { if (info.lastError) { - return <ErrorMessage title={info.lastError.hint} /> + return <ErrorMessage title={info.lastError.hint} />; } if (info.backupProblem) { switch (info.backupProblem.type) { case "backup-conflicting-device": - return <ErrorMessage title={<Fragment> - <i18n.Translate>There is conflict with another backup from <b>{info.backupProblem.otherDeviceId}</b></i18n.Translate> - </Fragment>} /> + return ( + <ErrorMessage + title={ + <Fragment> + <i18n.Translate> + There is conflict with another backup from{" "} + <b>{info.backupProblem.otherDeviceId}</b> + </i18n.Translate> + </Fragment> + } + /> + ); case "backup-unreadable": - return <ErrorMessage title="Backup is not readable" /> + return <ErrorMessage title="Backup is not readable" />; default: - return <ErrorMessage title={<Fragment> - <i18n.Translate>Unknown backup problem: {JSON.stringify(info.backupProblem)}</i18n.Translate> - </Fragment>} /> + return ( + <ErrorMessage + title={ + <Fragment> + <i18n.Translate> + Unknown backup problem: {JSON.stringify(info.backupProblem)} + </i18n.Translate> + </Fragment> + } + /> + ); } } - return null + return <Fragment />; } -function colorByStatus(status: ProviderPaymentType) { - switch (status) { - case ProviderPaymentType.InsufficientBalance: - return 'rgb(223, 117, 20)' - case ProviderPaymentType.Unpaid: - return 'rgb(202, 60, 60)' - case ProviderPaymentType.Paid: - return 'rgb(28, 184, 65)' - case ProviderPaymentType.Pending: - return 'gray' - case ProviderPaymentType.InsufficientBalance: - return 'rgb(202, 60, 60)' - case ProviderPaymentType.TermsChanged: - return 'rgb(202, 60, 60)' - } -} +// function colorByStatus(status: ProviderPaymentType): string { +// switch (status) { +// case ProviderPaymentType.InsufficientBalance: +// return "rgb(223, 117, 20)"; +// case ProviderPaymentType.Unpaid: +// return "rgb(202, 60, 60)"; +// case ProviderPaymentType.Paid: +// return "rgb(28, 184, 65)"; +// case ProviderPaymentType.Pending: +// return "gray"; +// // case ProviderPaymentType.InsufficientBalance: +// // return "rgb(202, 60, 60)"; +// case ProviderPaymentType.TermsChanged: +// return "rgb(202, 60, 60)"; +// } +// } -function descriptionByStatus(status: ProviderPaymentStatus) { +function descriptionByStatus(status: ProviderPaymentStatus): VNode { switch (status.type) { // return i18n.str`no enough balance to make the payment` // return i18n.str`not paid yet` case ProviderPaymentType.Paid: case ProviderPaymentType.TermsChanged: - if (status.paidUntil.t_ms === 'never') { - return i18n.str`service paid` - } else { - return <Fragment> - <b>Backup valid until:</b> {format(status.paidUntil.t_ms, 'dd MMM yyyy')} - </Fragment> + if (status.paidUntil.t_ms === "never") { + return <span>{i18n.str`service paid`}</span>; } + return ( + <Fragment> + <b>Backup valid until:</b>{" "} + <Time timestamp={status.paidUntil} format="dd MMM yyyy" /> + </Fragment> + ); + case ProviderPaymentType.Unpaid: case ProviderPaymentType.InsufficientBalance: case ProviderPaymentType.Pending: - return '' + return <span />; } } diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx index ca524f4e2..8d7b65b3c 100644 --- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.stories.tsx @@ -15,26 +15,39 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { ReserveCreated as TestedComponent } from './ReserveCreated'; +import { createExample } from "../test-utils"; +import { ReserveCreated as TestedComponent } from "./ReserveCreated"; export default { - title: 'wallet/manual withdraw/reserve created', + title: "wallet/manual withdraw/reserve created", component: TestedComponent, - argTypes: { - } + argTypes: {}, }; - -export const InitialState = createExample(TestedComponent, { - reservePub: 'ASLKDJQWLKEJASLKDJSADLKASJDLKSADJ', - paytos: [ - 'payto://x-taler-bank/bank.taler:5882/exchangeminator?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG', - 'payto://x-taler-bank/international-bank.com/myaccount?amount=COL%3A1&message=Taler+Withdrawal+TYQTE7VA4M9GZQ4TR06YBNGA05AJGMFNSK4Q62NXR2FKNDB1J4EX', - ] +export const TalerBank = createExample(TestedComponent, { + reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", + payto: + "payto://x-taler-bank/bank.taler:5882/exchangeminator?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", + amount: { + currency: "USD", + value: 10, + fraction: 0, + }, + exchangeBaseUrl: "https://exchange.demo.taler.net", }); +export const IBAN = createExample(TestedComponent, { + reservePub: "A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", + payto: + "payto://iban/ASDQWEASDZXCASDQWE?amount=COL%3A1&message=Taler+Withdrawal+A05AJGMFNSK4Q62NXR2FKNDB1J4EXTYQTE7VA4M9GZQ4TR06YBNG", + amount: { + currency: "USD", + value: 10, + fraction: 0, + }, + exchangeBaseUrl: "https://exchange.demo.taler.net", +}); diff --git a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx index e01336e02..a72026ab8 100644 --- a/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx +++ b/packages/taler-wallet-webextension/src/wallet/ReserveCreated.tsx @@ -1,40 +1,155 @@ -import { Fragment, VNode } from "preact"; -import { useState } from "preact/hooks"; +import { + AmountJson, + Amounts, + parsePaytoUri, + PaytoUri, +} from "@gnu-taler/taler-util"; +import { Fragment, h, VNode } from "preact"; +import { useEffect, useState } from "preact/hooks"; import { QR } from "../components/QR"; -import { ButtonBox, FontIcon, WalletBox } from "../components/styled"; - +import { + ButtonDestructive, + ButtonPrimary, + WalletBox, + WarningBox, +} from "../components/styled"; export interface Props { reservePub: string; - paytos: string[]; + payto: string; + exchangeBaseUrl: string; + amount: AmountJson; onBack: () => void; } -export function ReserveCreated({ reservePub, paytos, onBack }: Props): VNode { - const [opened, setOpened] = useState(-1) +interface BankDetailsProps { + payto: PaytoUri; + exchangeBaseUrl: string; + subject: string; + amount: string; +} + +function Row({ + name, + value, + literal, +}: { + name: string; + value: string; + literal?: boolean; +}): VNode { + const [copied, setCopied] = useState(false); + function copyText(): void { + navigator.clipboard.writeText(value); + setCopied(true); + } + useEffect(() => { + setTimeout(() => { + setCopied(false); + }, 1000); + }, [copied]); + return ( + <tr> + <td> + {!copied ? ( + <ButtonPrimary small onClick={copyText}> + Copy + </ButtonPrimary> + ) : ( + <ButtonPrimary small disabled> + Copied + </ButtonPrimary> + )} + </td> + <td> + <b>{name}</b> + </td> + {literal ? ( + <td> + <pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}> + {value} + </pre> + </td> + ) : ( + <td>{value}</td> + )} + </tr> + ); +} + +function BankDetailsByPaytoType({ + payto, + subject, + exchangeBaseUrl, + amount, +}: BankDetailsProps): VNode { + const firstPart = !payto.isKnown ? ( + <Fragment> + <Row name="Account" value={payto.targetPath} /> + <Row name="Exchange" value={exchangeBaseUrl} /> + </Fragment> + ) : payto.targetType === "x-taler-bank" ? ( + <Fragment> + <Row name="Bank host" value={payto.host} /> + <Row name="Bank account" value={payto.account} /> + <Row name="Exchange" value={exchangeBaseUrl} /> + </Fragment> + ) : payto.targetType === "iban" ? ( + <Fragment> + <Row name="IBAN" value={payto.iban} /> + <Row name="Exchange" value={exchangeBaseUrl} /> + </Fragment> + ) : undefined; + return ( + <table> + {firstPart} + <Row name="Amount" value={amount} /> + <Row name="Subject" value={subject} literal /> + </table> + ); +} +export function ReserveCreated({ + reservePub, + payto, + onBack, + exchangeBaseUrl, + amount, +}: Props): VNode { + const paytoURI = parsePaytoUri(payto); + // const url = new URL(paytoURI?.targetPath); + if (!paytoURI) { + return <div>could not parse payto uri from exchange {payto}</div>; + } return ( <WalletBox> <section> - <h2>Reserve created!</h2> - <p>Now you need to send money to the exchange to one of the following accounts</p> - <p>To complete the setup of the reserve, you must now initiate a wire transfer using the given wire transfer subject and crediting the specified amount to the indicated account of the exchange.</p> + <h1>Bank transfer details</h1> + <p> + Please wire <b>{Amounts.stringify(amount)}</b> to: + </p> + <BankDetailsByPaytoType + amount={Amounts.stringify(amount)} + exchangeBaseUrl={exchangeBaseUrl} + payto={paytoURI} + subject={reservePub} + /> </section> <section> - <ul> - {paytos.map((href, idx) => { - const url = new URL(href) - return <li key={idx}><p> - <a href="" onClick={(e) => { setOpened(o => o === idx ? -1 : idx); e.preventDefault() }}>{url.pathname}</a> - {opened === idx && <Fragment> - <p>If your system supports RFC 8905, you can do this by opening <a href={href}>this URI</a> or scan the QR with your wallet</p> - <QR text={href} /> - </Fragment>} - </p></li> - })} - </ul> + <p> + <WarningBox> + Make sure to use the correct subject, otherwise the money will not + arrive in this wallet. + </WarningBox> + </p> + <p> + Alternative, you can also scan this QR code or open{" "} + <a href={payto}>this link</a> if you have a banking app installed that + supports RFC 8905 + </p> + <QR text={payto} /> </section> <footer> - <ButtonBox onClick={onBack}><FontIcon>←</FontIcon></ButtonBox> <div /> + <ButtonDestructive onClick={onBack}>Cancel withdraw</ButtonDestructive> </footer> </WalletBox> ); diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx index a04a0b4fd..6cc1368d5 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.stories.tsx @@ -15,39 +15,41 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ -import { createExample } from '../test-utils'; -import { SettingsView as TestedComponent } from './Settings'; +import { createExample } from "../test-utils"; +import { SettingsView as TestedComponent } from "./Settings"; export default { - title: 'wallet/settings', + title: "wallet/settings", component: TestedComponent, argTypes: { setDeviceName: () => Promise.resolve(), - } + }, }; export const AllOff = createExample(TestedComponent, { - deviceName: 'this-is-the-device-name', + deviceName: "this-is-the-device-name", setDeviceName: () => Promise.resolve(), }); export const OneChecked = createExample(TestedComponent, { - deviceName: 'this-is-the-device-name', + deviceName: "this-is-the-device-name", permissionsEnabled: true, setDeviceName: () => Promise.resolve(), }); export const WithOneExchange = createExample(TestedComponent, { - deviceName: 'this-is-the-device-name', + deviceName: "this-is-the-device-name", permissionsEnabled: true, setDeviceName: () => Promise.resolve(), - knownExchanges: [{ - currency: 'USD', - exchangeBaseUrl: 'http://exchange.taler', - paytoUris: ['payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator'] - }] + knownExchanges: [ + { + currency: "USD", + exchangeBaseUrl: "http://exchange.taler", + paytoUris: ["payto://x-taler-bank/bank.rpi.sebasjm.com/exchangeminator"], + }, + ], }); diff --git a/packages/taler-wallet-webextension/src/wallet/Settings.tsx b/packages/taler-wallet-webextension/src/wallet/Settings.tsx index 8d18586b1..8d8f3cdbc 100644 --- a/packages/taler-wallet-webextension/src/wallet/Settings.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Settings.tsx @@ -14,7 +14,6 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ - import { ExchangeListItem, i18n } from "@gnu-taler/taler-util"; import { VNode, h, Fragment } from "preact"; import { Checkbox } from "../components/Checkbox"; @@ -30,18 +29,28 @@ import * as wxApi from "../wxApi"; export function SettingsPage(): VNode { const [permissionsEnabled, togglePermissions] = useExtendedPermissions(); - const { devMode, toggleDevMode } = useDevContext() - const { name, update } = useBackupDeviceName() - const [lang, changeLang] = useLang() + const { devMode, toggleDevMode } = useDevContext(); + const { name, update } = useBackupDeviceName(); + const [lang, changeLang] = useLang(); const exchangesHook = useAsyncAsHook(() => wxApi.listExchanges()); - return <SettingsView - lang={lang} changeLang={changeLang} - knownExchanges={!exchangesHook || exchangesHook.hasError ? [] : exchangesHook.response.exchanges} - deviceName={name} setDeviceName={update} - permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions} - developerMode={devMode} toggleDeveloperMode={toggleDevMode} - />; + return ( + <SettingsView + lang={lang} + changeLang={changeLang} + knownExchanges={ + !exchangesHook || exchangesHook.hasError + ? [] + : exchangesHook.response.exchanges + } + deviceName={name} + setDeviceName={update} + permissionsEnabled={permissionsEnabled} + togglePermissions={togglePermissions} + developerMode={devMode} + toggleDeveloperMode={toggleDevMode} + /> + ); } export interface ViewProps { @@ -56,52 +65,72 @@ export interface ViewProps { knownExchanges: Array<ExchangeListItem>; } -import { strings as messages } from '../i18n/strings' +import { strings as messages } from "../i18n/strings"; type LangsNames = { - [P in keyof typeof messages]: string -} + [P in keyof typeof messages]: string; +}; const names: LangsNames = { - es: 'Español [es]', - en: 'English [en]', - fr: 'Français [fr]', - de: 'Deutsch [de]', - sv: 'Svenska [sv]', - it: 'Italiano [it]', -} + es: "Español [es]", + en: "English [en]", + fr: "Français [fr]", + de: "Deutsch [de]", + sv: "Svenska [sv]", + it: "Italiano [it]", +}; - -export function SettingsView({ knownExchanges, lang, changeLang, deviceName, setDeviceName, permissionsEnabled, togglePermissions, developerMode, toggleDeveloperMode }: ViewProps): VNode { +export function SettingsView({ + knownExchanges, + lang, + changeLang, + deviceName, + setDeviceName, + permissionsEnabled, + togglePermissions, + developerMode, + toggleDeveloperMode, +}: ViewProps): VNode { return ( <WalletBox> <section> - - <h2><i18n.Translate>Known exchanges</i18n.Translate></h2> - {!knownExchanges || !knownExchanges.length ? <div> - No exchange yet! - </div> : + <h2> + <i18n.Translate>Known exchanges</i18n.Translate> + </h2> + {!knownExchanges || !knownExchanges.length ? ( + <div>No exchange yet!</div> + ) : ( <table> - {knownExchanges.map(e => <tr> - <td>{e.currency}</td> - <td><a href={e.exchangeBaseUrl}>{e.exchangeBaseUrl}</a></td> - </tr>)} + {knownExchanges.map((e) => ( + <tr> + <td>{e.currency}</td> + <td> + <a href={e.exchangeBaseUrl}>{e.exchangeBaseUrl}</a> + </td> + </tr> + ))} </table> - } - - <h2><i18n.Translate>Permissions</i18n.Translate></h2> - <Checkbox label="Automatically open wallet based on page content" + )} + + <h2> + <i18n.Translate>Permissions</i18n.Translate> + </h2> + <Checkbox + label="Automatically open wallet based on page content" name="perm" description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" - enabled={permissionsEnabled} onToggle={togglePermissions} + enabled={permissionsEnabled} + onToggle={togglePermissions} /> <h2>Config</h2> - <Checkbox label="Developer mode" + <Checkbox + label="Developer mode" name="devMode" description="(More options and information useful for debugging)" - enabled={developerMode} onToggle={toggleDeveloperMode} + enabled={developerMode} + onToggle={toggleDeveloperMode} /> </section> </WalletBox> - ) + ); } diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx index 535509cef..c9a3f47cb 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.stories.tsx @@ -15,110 +15,116 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ + * + * @author Sebastian Javier Marchano (sebasjm) + */ import { PaymentStatus, - TransactionCommon, TransactionDeposit, TransactionPayment, - TransactionRefresh, TransactionRefund, TransactionTip, TransactionType, + TransactionCommon, + TransactionDeposit, + TransactionPayment, + TransactionRefresh, + TransactionRefund, + TransactionTip, + TransactionType, TransactionWithdrawal, - WithdrawalType -} from '@gnu-taler/taler-util'; -import { createExample } from '../test-utils'; -import { TransactionView as TestedComponent } from './Transaction'; + WithdrawalType, +} from "@gnu-taler/taler-util"; +import { createExample } from "../test-utils"; +import { TransactionView as TestedComponent } from "./Transaction"; export default { - title: 'wallet/history/details', + title: "wallet/history/details", component: TestedComponent, argTypes: { - onRetry: { action: 'onRetry' }, - onDelete: { action: 'onDelete' }, - onBack: { action: 'onBack' }, - } + onRetry: { action: "onRetry" }, + onDelete: { action: "onDelete" }, + onBack: { action: "onBack" }, + }, }; const commonTransaction = { - amountRaw: 'KUDOS:11', - amountEffective: 'KUDOS:9.2', + amountRaw: "KUDOS:11", + amountEffective: "KUDOS:9.2", pending: false, timestamp: { - t_ms: new Date().getTime() + t_ms: new Date().getTime(), }, - transactionId: '12', -} as TransactionCommon + transactionId: "12", +} as TransactionCommon; const exampleData = { withdraw: { ...commonTransaction, type: TransactionType.Withdrawal, - exchangeBaseUrl: 'http://exchange.taler', + exchangeBaseUrl: "http://exchange.taler", withdrawalDetails: { confirmed: false, - exchangePaytoUris: ['payto://x-taler-bank/bank/account'], + exchangePaytoUris: ["payto://x-taler-bank/bank/account"], type: WithdrawalType.ManualTransfer, - } + }, } as TransactionWithdrawal, payment: { ...commonTransaction, - amountEffective: 'KUDOS:11', + amountEffective: "KUDOS:11", type: TransactionType.Payment, info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'the merchant', + name: "the merchant", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], summary: "Essay: Why the Devil's Advocate Doesn't Help Reach the Truth", - fulfillmentMessage: '', + fulfillmentMessage: "", }, - proposalId: '1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + proposalId: "1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", status: PaymentStatus.Accepted, } as TransactionPayment, deposit: { ...commonTransaction, type: TransactionType.Deposit, - depositGroupId: '#groupId', - targetPaytoUri: 'payto://x-taler-bank/bank/account', + depositGroupId: "#groupId", + targetPaytoUri: "payto://x-taler-bank/bank/account", } as TransactionDeposit, refresh: { ...commonTransaction, type: TransactionType.Refresh, - exchangeBaseUrl: 'http://exchange.taler', + exchangeBaseUrl: "http://exchange.taler", } as TransactionRefresh, tip: { ...commonTransaction, type: TransactionType.Tip, - merchantBaseUrl: 'http://merchant.taler', + merchantBaseUrl: "http://merchant.taler", } as TransactionTip, refund: { ...commonTransaction, type: TransactionType.Refund, - refundedTransactionId: 'payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0', + refundedTransactionId: + "payment:1EMJJH8EP1NX3XF7733NCYS2DBEJW4Q2KA5KEB37MCQJQ8Q5HMC0", info: { - contractTermsHash: 'ASDZXCASD', + contractTermsHash: "ASDZXCASD", merchant: { - name: 'the merchant', + name: "the merchant", }, - orderId: '2021.167-03NPY6MCYMVGT', + orderId: "2021.167-03NPY6MCYMVGT", products: [], - summary: 'the summary', - fulfillmentMessage: '', + summary: "the summary", + fulfillmentMessage: "", }, } as TransactionRefund, -} +}; const transactionError = { code: 2000, details: "details", hint: "this is a hint for the error", - message: 'message' -} + message: "message", +}; export const Withdraw = createExample(TestedComponent, { - transaction: exampleData.withdraw + transaction: exampleData.withdraw, }); export const WithdrawError = createExample(TestedComponent, { @@ -132,24 +138,22 @@ export const WithdrawPending = createExample(TestedComponent, { transaction: { ...exampleData.withdraw, pending: true }, }); - export const Payment = createExample(TestedComponent, { - transaction: exampleData.payment + transaction: exampleData.payment, }); export const PaymentError = createExample(TestedComponent, { transaction: { ...exampleData.payment, - error: transactionError + error: transactionError, }, }); export const PaymentWithoutFee = createExample(TestedComponent, { transaction: { ...exampleData.payment, - amountRaw: 'KUDOS:11', - - } + amountRaw: "KUDOS:11", + }, }); export const PaymentPending = createExample(TestedComponent, { @@ -161,27 +165,33 @@ export const PaymentWithProducts = createExample(TestedComponent, { ...exampleData.payment, info: { ...exampleData.payment.info, - summary: 'this order has 5 products', - products: [{ - description: 't-shirt', - unit: 'shirts', - quantity: 1, - }, { - description: 't-shirt', - unit: 'shirts', - quantity: 1, - }, { - description: 'e-book', - }, { - description: 'beer', - unit: 'pint', - quantity: 15, - }, { - description: 'beer', - unit: 'pint', - quantity: 15, - }] - } + summary: "this order has 5 products", + products: [ + { + description: "t-shirt", + unit: "shirts", + quantity: 1, + }, + { + description: "t-shirt", + unit: "shirts", + quantity: 1, + }, + { + description: "e-book", + }, + { + description: "beer", + unit: "pint", + quantity: 15, + }, + { + description: "beer", + unit: "pint", + quantity: 15, + }, + ], + }, } as TransactionPayment, }); @@ -190,75 +200,79 @@ export const PaymentWithLongSummary = createExample(TestedComponent, { ...exampleData.payment, info: { ...exampleData.payment.info, - summary: 'this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, ', - products: [{ - description: 'an xl sized t-shirt with some drawings on it, color pink', - unit: 'shirts', - quantity: 1, - }, { - description: 'beer', - unit: 'pint', - quantity: 15, - }] - } + summary: + "this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, this is a very long summary that will occupy severals lines, ", + products: [ + { + description: + "an xl sized t-shirt with some drawings on it, color pink", + unit: "shirts", + quantity: 1, + }, + { + description: "beer", + unit: "pint", + quantity: 15, + }, + ], + }, } as TransactionPayment, }); - export const Deposit = createExample(TestedComponent, { - transaction: exampleData.deposit + transaction: exampleData.deposit, }); export const DepositError = createExample(TestedComponent, { transaction: { ...exampleData.deposit, - error: transactionError + error: transactionError, }, }); export const DepositPending = createExample(TestedComponent, { - transaction: { ...exampleData.deposit, pending: true } + transaction: { ...exampleData.deposit, pending: true }, }); export const Refresh = createExample(TestedComponent, { - transaction: exampleData.refresh + transaction: exampleData.refresh, }); export const RefreshError = createExample(TestedComponent, { transaction: { ...exampleData.refresh, - error: transactionError + error: transactionError, }, }); export const Tip = createExample(TestedComponent, { - transaction: exampleData.tip + transaction: exampleData.tip, }); export const TipError = createExample(TestedComponent, { transaction: { ...exampleData.tip, - error: transactionError + error: transactionError, }, }); export const TipPending = createExample(TestedComponent, { - transaction: { ...exampleData.tip, pending: true } + transaction: { ...exampleData.tip, pending: true }, }); export const Refund = createExample(TestedComponent, { - transaction: exampleData.refund + transaction: exampleData.refund, }); export const RefundError = createExample(TestedComponent, { transaction: { ...exampleData.refund, - error: transactionError + error: transactionError, }, }); export const RefundPending = createExample(TestedComponent, { - transaction: { ...exampleData.refund, pending: true } + transaction: { ...exampleData.refund, pending: true }, }); export const RefundWithProducts = createExample(TestedComponent, { @@ -266,11 +280,14 @@ export const RefundWithProducts = createExample(TestedComponent, { ...exampleData.refund, info: { ...exampleData.refund.info, - products: [{ - description: 't-shirt', - }, { - description: 'beer', - }] - } + products: [ + { + description: "t-shirt", + }, + { + description: "beer", + }, + ], + }, } as TransactionRefund, }); diff --git a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx index cf41efb59..1472efb40 100644 --- a/packages/taler-wallet-webextension/src/wallet/Transaction.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Transaction.tsx @@ -14,27 +14,42 @@ TALER; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AmountLike, Amounts, i18n, Transaction, TransactionType } from "@gnu-taler/taler-util"; -import { format } from "date-fns"; -import { JSX, VNode } from "preact"; -import { route } from 'preact-router'; +import { + AmountLike, + Amounts, + i18n, + Transaction, + TransactionType, +} from "@gnu-taler/taler-util"; +import { h, VNode } from "preact"; +import { route } from "preact-router"; import { useEffect, useState } from "preact/hooks"; import emptyImg from "../../static/img/empty.png"; import { ErrorMessage } from "../components/ErrorMessage"; import { Part } from "../components/Part"; -import { ButtonBox, ButtonBoxDestructive, ButtonPrimary, FontIcon, ListOfProducts, RowBorderGray, SmallLightText, WalletBox, WarningBox } from "../components/styled"; +import { + Button, + ButtonDestructive, + ButtonPrimary, + ListOfProducts, + RowBorderGray, + SmallLightText, + WalletBox, + WarningBox, +} from "../components/styled"; +import { Time } from "../components/Time"; import { Pages } from "../NavigationBar"; import * as wxApi from "../wxApi"; -export function TransactionPage({ tid }: { tid: string; }): JSX.Element { - const [transaction, setTransaction] = useState< - Transaction | undefined - >(undefined); +export function TransactionPage({ tid }: { tid: string }): VNode { + const [transaction, setTransaction] = useState<Transaction | undefined>( + undefined, + ); useEffect(() => { const fetchData = async (): Promise<void> => { const res = await wxApi.getTransactions(); - const ts = res.transactions.filter(t => t.transactionId === tid); + const ts = res.transactions.filter((t) => t.transactionId === tid); if (ts.length === 1) { setTransaction(ts[0]); } else { @@ -45,13 +60,22 @@ export function TransactionPage({ tid }: { tid: string; }): JSX.Element { }, [tid]); if (!transaction) { - return <div><i18n.Translate>Loading ...</i18n.Translate></div>; + return ( + <div> + <i18n.Translate>Loading ...</i18n.Translate> + </div> + ); } - return <TransactionView - transaction={transaction} - onDelete={() => wxApi.deleteTransaction(tid).then(_ => history.go(-1))} - onRetry={() => wxApi.retryTransaction(tid).then(_ => history.go(-1))} - onBack={() => { route(Pages.history) }} />; + return ( + <TransactionView + transaction={transaction} + onDelete={() => wxApi.deleteTransaction(tid).then(() => history.go(-1))} + onRetry={() => wxApi.retryTransaction(tid).then(() => history.go(-1))} + onBack={() => { + route(Pages.history); + }} + /> + ); } export interface WalletTransactionProps { @@ -61,173 +85,295 @@ export interface WalletTransactionProps { onBack: () => void; } -export function TransactionView({ transaction, onDelete, onRetry, onBack }: WalletTransactionProps) { - - function TransactionTemplate({ children }: { children: VNode[] }) { - return <WalletBox> - <section style={{ padding: 8, textAlign: 'center'}}> - <ErrorMessage title={transaction?.error?.hint} /> - {transaction.pending && <WarningBox>This transaction is not completed</WarningBox>} - </section> - <section> - <div style={{ textAlign: 'center' }}> - {children} - </div> - </section> - <footer> - <ButtonBox onClick={onBack}><i18n.Translate> <FontIcon>←</FontIcon> </i18n.Translate></ButtonBox> - <div> - {transaction?.error ? <ButtonPrimary onClick={onRetry}><i18n.Translate>retry</i18n.Translate></ButtonPrimary> : null} - <ButtonBoxDestructive onClick={onDelete}><i18n.Translate>🗑</i18n.Translate></ButtonBoxDestructive> - </div> - </footer> - </WalletBox> +export function TransactionView({ + transaction, + onDelete, + onRetry, + onBack, +}: WalletTransactionProps): VNode { + function TransactionTemplate({ children }: { children: VNode[] }): VNode { + return ( + <WalletBox> + <section style={{ padding: 8, textAlign: "center" }}> + <ErrorMessage title={transaction?.error?.hint} /> + {transaction.pending && ( + <WarningBox> + This transaction is not completed + <a href="">more info...</a> + </WarningBox> + )} + </section> + <section> + <div style={{ textAlign: "center" }}>{children}</div> + </section> + <footer> + <Button onClick={onBack}> + <i18n.Translate> < Back </i18n.Translate> + </Button> + <div> + {transaction?.error ? ( + <ButtonPrimary onClick={onRetry}> + <i18n.Translate>retry</i18n.Translate> + </ButtonPrimary> + ) : null} + <ButtonDestructive onClick={onDelete}> + <i18n.Translate> Forget </i18n.Translate> + </ButtonDestructive> + </div> + </footer> + </WalletBox> + ); } - function amountToString(text: AmountLike) { - const aj = Amounts.jsonifyAmount(text) - const amount = Amounts.stringifyValue(aj) - return `${amount} ${aj.currency}` + function amountToString(text: AmountLike): string { + const aj = Amounts.jsonifyAmount(text); + const amount = Amounts.stringifyValue(aj); + return `${amount} ${aj.currency}`; } - if (transaction.type === TransactionType.Withdrawal) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), - ).amount - return <TransactionTemplate> - <h2>Withdrawal</h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part title="Total withdrawn" text={amountToString(transaction.amountEffective)} kind='positive' /> - <Part title="Chosen amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part title="Exchange fee" text={amountToString(fee)} kind='negative' /> - <Part title="Exchange" text={new URL(transaction.exchangeBaseUrl).hostname} kind='neutral' /> - </TransactionTemplate> + ).amount; + return ( + <TransactionTemplate> + <h2>Withdrawal</h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total withdrawn" + text={amountToString(transaction.amountEffective)} + kind="positive" + /> + <Part + big + title="Chosen amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part + big + title="Exchange fee" + text={amountToString(fee)} + kind="negative" + /> + <Part + title="Exchange" + text={new URL(transaction.exchangeBaseUrl).hostname} + kind="neutral" + /> + </TransactionTemplate> + ); } - const showLargePic = () => { - - } + const showLargePic = (): void => { + return; + }; if (transaction.type === TransactionType.Payment) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountEffective), Amounts.parseOrThrow(transaction.amountRaw), - ).amount - - return <TransactionTemplate> - <h2>Payment </h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part big title="Total paid" text={amountToString(transaction.amountEffective)} kind='negative' /> - <Part big title="Purchase amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part big title="Fee" text={amountToString(fee)} kind='negative' /> - <Part title="Merchant" text={transaction.info.merchant.name} kind='neutral' /> - <Part title="Purchase" text={transaction.info.summary} kind='neutral' /> - <Part title="Receipt" text={`#${transaction.info.orderId}`} kind='neutral' /> + ).amount; - <div> - {transaction.info.products && transaction.info.products.length > 0 && - <ListOfProducts> - {transaction.info.products.map((p, k) => <RowBorderGray key={k}> - <a href="#" onClick={showLargePic}> - <img src={p.image ? p.image : emptyImg} /> - </a> - <div> - {p.quantity && p.quantity > 0 && <SmallLightText>x {p.quantity} {p.unit}</SmallLightText>} - <div>{p.description}</div> - </div> - </RowBorderGray>)} - </ListOfProducts> - } - </div> - </TransactionTemplate> + return ( + <TransactionTemplate> + <h2>Payment </h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total paid" + text={amountToString(transaction.amountEffective)} + kind="negative" + /> + <Part + big + title="Purchase amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part big title="Fee" text={amountToString(fee)} kind="negative" /> + <Part + title="Merchant" + text={transaction.info.merchant.name} + kind="neutral" + /> + <Part title="Purchase" text={transaction.info.summary} kind="neutral" /> + <Part + title="Receipt" + text={`#${transaction.info.orderId}`} + kind="neutral" + /> + + <div> + {transaction.info.products && transaction.info.products.length > 0 && ( + <ListOfProducts> + {transaction.info.products.map((p, k) => ( + <RowBorderGray key={k}> + <a href="#" onClick={showLargePic}> + <img src={p.image ? p.image : emptyImg} /> + </a> + <div> + {p.quantity && p.quantity > 0 && ( + <SmallLightText> + x {p.quantity} {p.unit} + </SmallLightText> + )} + <div>{p.description}</div> + </div> + </RowBorderGray> + ))} + </ListOfProducts> + )} + </div> + </TransactionTemplate> + ); } if (transaction.type === TransactionType.Deposit) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), - ).amount - return <TransactionTemplate> - <h2>Deposit </h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part big title="Total deposit" text={amountToString(transaction.amountEffective)} kind='negative' /> - <Part big title="Purchase amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part big title="Fee" text={amountToString(fee)} kind='negative' /> - </TransactionTemplate> + ).amount; + return ( + <TransactionTemplate> + <h2>Deposit </h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total deposit" + text={amountToString(transaction.amountEffective)} + kind="negative" + /> + <Part + big + title="Purchase amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part big title="Fee" text={amountToString(fee)} kind="negative" /> + </TransactionTemplate> + ); } if (transaction.type === TransactionType.Refresh) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), - ).amount - return <TransactionTemplate> - <h2>Refresh</h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part big title="Total refresh" text={amountToString(transaction.amountEffective)} kind='negative' /> - <Part big title="Refresh amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part big title="Fee" text={amountToString(fee)} kind='negative' /> - </TransactionTemplate> + ).amount; + return ( + <TransactionTemplate> + <h2>Refresh</h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total refresh" + text={amountToString(transaction.amountEffective)} + kind="negative" + /> + <Part + big + title="Refresh amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part big title="Fee" text={amountToString(fee)} kind="negative" /> + </TransactionTemplate> + ); } if (transaction.type === TransactionType.Tip) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), - ).amount - return <TransactionTemplate> - <h2>Tip</h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part big title="Total tip" text={amountToString(transaction.amountEffective)} kind='positive' /> - <Part big title="Received amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part big title="Fee" text={amountToString(fee)} kind='negative' /> - </TransactionTemplate> + ).amount; + return ( + <TransactionTemplate> + <h2>Tip</h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total tip" + text={amountToString(transaction.amountEffective)} + kind="positive" + /> + <Part + big + title="Received amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part big title="Fee" text={amountToString(fee)} kind="negative" /> + </TransactionTemplate> + ); } if (transaction.type === TransactionType.Refund) { const fee = Amounts.sub( Amounts.parseOrThrow(transaction.amountRaw), Amounts.parseOrThrow(transaction.amountEffective), - ).amount - return <TransactionTemplate> - <h2>Refund</h2> - <div>{transaction.timestamp.t_ms === 'never' ? 'never' : format(transaction.timestamp.t_ms, 'dd MMMM yyyy, HH:mm')}</div> - <br /> - <Part big title="Total refund" text={amountToString(transaction.amountEffective)} kind='positive' /> - <Part big title="Refund amount" text={amountToString(transaction.amountRaw)} kind='neutral' /> - <Part big title="Fee" text={amountToString(fee)} kind='negative' /> - <Part title="Merchant" text={transaction.info.merchant.name} kind='neutral' /> - <Part title="Purchase" text={transaction.info.summary} kind='neutral' /> - <Part title="Receipt" text={`#${transaction.info.orderId}`} kind='neutral' /> - - <p> - {transaction.info.summary} - </p> - <div> - {transaction.info.products && transaction.info.products.length > 0 && - <ListOfProducts> - {transaction.info.products.map((p, k) => <RowBorderGray key={k}> - <a href="#" onClick={showLargePic}> - <img src={p.image ? p.image : emptyImg} /> - </a> - <div> - {p.quantity && p.quantity > 0 && <SmallLightText>x {p.quantity} {p.unit}</SmallLightText>} - <div>{p.description}</div> - </div> - </RowBorderGray>)} - </ListOfProducts> - } - </div> - </TransactionTemplate> - } + ).amount; + return ( + <TransactionTemplate> + <h2>Refund</h2> + <Time timestamp={transaction.timestamp} format="dd MMMM yyyy, HH:mm" /> + <br /> + <Part + big + title="Total refund" + text={amountToString(transaction.amountEffective)} + kind="positive" + /> + <Part + big + title="Refund amount" + text={amountToString(transaction.amountRaw)} + kind="neutral" + /> + <Part big title="Fee" text={amountToString(fee)} kind="negative" /> + <Part + title="Merchant" + text={transaction.info.merchant.name} + kind="neutral" + /> + <Part title="Purchase" text={transaction.info.summary} kind="neutral" /> + <Part + title="Receipt" + text={`#${transaction.info.orderId}`} + kind="neutral" + /> + <p>{transaction.info.summary}</p> + <div> + {transaction.info.products && transaction.info.products.length > 0 && ( + <ListOfProducts> + {transaction.info.products.map((p, k) => ( + <RowBorderGray key={k}> + <a href="#" onClick={showLargePic}> + <img src={p.image ? p.image : emptyImg} /> + </a> + <div> + {p.quantity && p.quantity > 0 && ( + <SmallLightText> + x {p.quantity} {p.unit} + </SmallLightText> + )} + <div>{p.description}</div> + </div> + </RowBorderGray> + ))} + </ListOfProducts> + )} + </div> + </TransactionTemplate> + ); + } - return <div></div> + return <div />; } diff --git a/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx b/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx index 6579450b3..7e6588fac 100644 --- a/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Welcome.stories.tsx @@ -15,16 +15,15 @@ */ /** -* -* @author Sebastian Javier Marchano (sebasjm) -*/ - -import { createExample } from '../test-utils'; -import { View as TestedComponent } from './Welcome'; + * + * @author Sebastian Javier Marchano (sebasjm) + */ +import { createExample } from "../test-utils"; +import { View as TestedComponent } from "./Welcome"; export default { - title: 'wallet/welcome', + title: "wallet/welcome", component: TestedComponent, }; @@ -32,11 +31,11 @@ export const Normal = createExample(TestedComponent, { permissionsEnabled: true, diagnostics: { errors: [], - walletManifestVersion: '1.0', - walletManifestDisplayVersion: '1.0', + walletManifestVersion: "1.0", + walletManifestDisplayVersion: "1.0", firefoxIdbProblem: false, dbOutdated: false, - } + }, }); export const TimedoutDiagnostics = createExample(TestedComponent, { @@ -47,4 +46,3 @@ export const TimedoutDiagnostics = createExample(TestedComponent, { export const RunningDiagnostics = createExample(TestedComponent, { permissionsEnabled: false, }); - diff --git a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx index d11070d9a..a6dd040e4 100644 --- a/packages/taler-wallet-webextension/src/wallet/Welcome.tsx +++ b/packages/taler-wallet-webextension/src/wallet/Welcome.tsx @@ -20,50 +20,61 @@ * @author Florian Dold */ -import { JSX } from "preact/jsx-runtime"; import { Checkbox } from "../components/Checkbox"; import { useExtendedPermissions } from "../hooks/useExtendedPermissions"; import { Diagnostics } from "../components/Diagnostics"; import { WalletBox } from "../components/styled"; import { useDiagnostics } from "../hooks/useDiagnostics"; import { WalletDiagnostics } from "@gnu-taler/taler-util"; -import { h } from 'preact'; +import { h, VNode } from "preact"; -export function WelcomePage() { - const [permissionsEnabled, togglePermissions] = useExtendedPermissions() - const [diagnostics, timedOut] = useDiagnostics() - return <View - permissionsEnabled={permissionsEnabled} togglePermissions={togglePermissions} - diagnostics={diagnostics} timedOut={timedOut} - /> +export function WelcomePage(): VNode { + const [permissionsEnabled, togglePermissions] = useExtendedPermissions(); + const [diagnostics, timedOut] = useDiagnostics(); + return ( + <View + permissionsEnabled={permissionsEnabled} + togglePermissions={togglePermissions} + diagnostics={diagnostics} + timedOut={timedOut} + /> + ); } export interface ViewProps { - permissionsEnabled: boolean, - togglePermissions: () => void, - diagnostics: WalletDiagnostics | undefined, - timedOut: boolean, + permissionsEnabled: boolean; + togglePermissions: () => void; + diagnostics: WalletDiagnostics | undefined; + timedOut: boolean; } -export function View({ permissionsEnabled, togglePermissions, diagnostics, timedOut }: ViewProps): JSX.Element { - return (<WalletBox> - <h1>Browser Extension Installed!</h1> - <div> - <p>Thank you for installing the wallet.</p> - <Diagnostics diagnostics={diagnostics} timedOut={timedOut} /> - <h2>Permissions</h2> - <Checkbox label="Automatically open wallet based on page content" - name="perm" - description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" - enabled={permissionsEnabled} onToggle={togglePermissions} - /> - <h2>Next Steps</h2> - <a href="https://demo.taler.net/" style={{ display: "block" }}> - Try the demo » - </a> - <a href="https://demo.taler.net/" style={{ display: "block" }}> - Learn how to top up your wallet balance » - </a> - </div> - </WalletBox> +export function View({ + permissionsEnabled, + togglePermissions, + diagnostics, + timedOut, +}: ViewProps): VNode { + return ( + <WalletBox> + <h1>Browser Extension Installed!</h1> + <div> + <p>Thank you for installing the wallet.</p> + <Diagnostics diagnostics={diagnostics} timedOut={timedOut} /> + <h2>Permissions</h2> + <Checkbox + label="Automatically open wallet based on page content" + name="perm" + description="(Enabling this option below will make using the wallet faster, but requires more permissions from your browser.)" + enabled={permissionsEnabled} + onToggle={togglePermissions} + /> + <h2>Next Steps</h2> + <a href="https://demo.taler.net/" style={{ display: "block" }}> + Try the demo » + </a> + <a href="https://demo.taler.net/" style={{ display: "block" }}> + Learn how to top up your wallet balance » + </a> + </div> + </WalletBox> ); } diff --git a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx index 023ee94c5..f097d58b5 100644 --- a/packages/taler-wallet-webextension/src/walletEntryPoint.tsx +++ b/packages/taler-wallet-webextension/src/walletEntryPoint.tsx @@ -21,7 +21,7 @@ */ import { setupI18n } from "@gnu-taler/taler-util"; -import { createHashHistory } from 'history'; +import { createHashHistory } from "history"; import { Fragment, h, render } from "preact"; import Router, { route, Route } from "preact-router"; import { useEffect } from "preact/hooks"; @@ -29,22 +29,19 @@ import { LogoHeader } from "./components/LogoHeader"; import { DevContextProvider } from "./context/devContext"; import { PayPage } from "./cta/Pay"; import { RefundPage } from "./cta/Refund"; -import { TipPage } from './cta/Tip'; +import { TipPage } from "./cta/Tip"; import { WithdrawPage } from "./cta/Withdraw"; import { strings } from "./i18n/strings"; -import { - Pages, WalletNavBar -} from "./NavigationBar"; +import { Pages, WalletNavBar } from "./NavigationBar"; import { BalancePage } from "./wallet/BalancePage"; import { HistoryPage } from "./wallet/History"; import { SettingsPage } from "./wallet/Settings"; -import { TransactionPage } from './wallet/Transaction'; +import { TransactionPage } from "./wallet/Transaction"; import { WelcomePage } from "./wallet/Welcome"; -import { BackupPage } from './wallet/BackupPage'; +import { BackupPage } from "./wallet/BackupPage"; import { DeveloperPage } from "./popup/Debug.js"; import { ManualWithdrawPage } from "./wallet/ManualWithdrawPage.js"; - function main(): void { try { const container = document.getElementById("container"); @@ -69,51 +66,86 @@ if (document.readyState === "loading") { } function withLogoAndNavBar(Component: any) { - return (props: any) => <Fragment> - <LogoHeader /> - <WalletNavBar /> - <Component {...props} /> - </Fragment> + return (props: any) => ( + <Fragment> + <LogoHeader /> + <WalletNavBar /> + <Component {...props} /> + </Fragment> + ); } function Application() { - return <div> - <DevContextProvider> - <Router history={createHashHistory()} > - - <Route path={Pages.welcome} component={withLogoAndNavBar(WelcomePage)} /> - - <Route path={Pages.history} component={withLogoAndNavBar(HistoryPage)} /> - <Route path={Pages.transaction} component={withLogoAndNavBar(TransactionPage)} /> - <Route path={Pages.balance} component={withLogoAndNavBar(BalancePage)} - goToWalletManualWithdraw={() => route(Pages.manual_withdraw)} - /> - <Route path={Pages.settings} component={withLogoAndNavBar(SettingsPage)} /> - <Route path={Pages.backup} component={withLogoAndNavBar(BackupPage)} /> - - <Route path={Pages.manual_withdraw} component={withLogoAndNavBar(ManualWithdrawPage)} /> - - <Route path={Pages.reset_required} component={() => <div>no yet implemented</div>} /> - <Route path={Pages.payback} component={() => <div>no yet implemented</div>} /> - <Route path={Pages.return_coins} component={() => <div>no yet implemented</div>} /> - - <Route path={Pages.dev} component={withLogoAndNavBar(DeveloperPage)} /> - - {/** call to action */} - <Route path={Pages.pay} component={PayPage} /> - <Route path={Pages.refund} component={RefundPage} /> - <Route path={Pages.tips} component={TipPage} /> - <Route path={Pages.withdraw} component={WithdrawPage} /> - - <Route default component={Redirect} to={Pages.history} /> - </Router> - </DevContextProvider> - </div> + return ( + <div> + <DevContextProvider> + <Router history={createHashHistory()}> + <Route + path={Pages.welcome} + component={withLogoAndNavBar(WelcomePage)} + /> + + <Route + path={Pages.history} + component={withLogoAndNavBar(HistoryPage)} + /> + <Route + path={Pages.transaction} + component={withLogoAndNavBar(TransactionPage)} + /> + <Route + path={Pages.balance} + component={withLogoAndNavBar(BalancePage)} + goToWalletManualWithdraw={() => route(Pages.manual_withdraw)} + /> + <Route + path={Pages.settings} + component={withLogoAndNavBar(SettingsPage)} + /> + <Route + path={Pages.backup} + component={withLogoAndNavBar(BackupPage)} + /> + + <Route + path={Pages.manual_withdraw} + component={withLogoAndNavBar(ManualWithdrawPage)} + /> + + <Route + path={Pages.reset_required} + component={() => <div>no yet implemented</div>} + /> + <Route + path={Pages.payback} + component={() => <div>no yet implemented</div>} + /> + <Route + path={Pages.return_coins} + component={() => <div>no yet implemented</div>} + /> + + <Route + path={Pages.dev} + component={withLogoAndNavBar(DeveloperPage)} + /> + + {/** call to action */} + <Route path={Pages.pay} component={PayPage} /> + <Route path={Pages.refund} component={RefundPage} /> + <Route path={Pages.tips} component={TipPage} /> + <Route path={Pages.withdraw} component={WithdrawPage} /> + + <Route default component={Redirect} to={Pages.history} /> + </Router> + </DevContextProvider> + </div> + ); } function Redirect({ to }: { to: string }): null { useEffect(() => { - route(to, true) - }) - return null + route(to, true); + }); + return null; } diff --git a/packages/taler-wallet-webextension/src/wxApi.ts b/packages/taler-wallet-webextension/src/wxApi.ts index 92597cbd2..90cfd3ed6 100644 --- a/packages/taler-wallet-webextension/src/wxApi.ts +++ b/packages/taler-wallet-webextension/src/wxApi.ts @@ -47,7 +47,12 @@ import { AddExchangeRequest, GetExchangeTosResult, } from "@gnu-taler/taler-util"; -import { AddBackupProviderRequest, BackupProviderState, OperationFailedError, RemoveBackupProviderRequest } from "@gnu-taler/taler-wallet-core"; +import { + AddBackupProviderRequest, + BackupProviderState, + OperationFailedError, + RemoveBackupProviderRequest, +} from "@gnu-taler/taler-wallet-core"; import { BackupInfo } from "@gnu-taler/taler-wallet-core"; import { ExchangeWithdrawDetails } from "@gnu-taler/taler-wallet-core/src/operations/withdraw"; @@ -149,78 +154,89 @@ interface CurrencyInfo { pub: string; } interface ListOfKnownCurrencies { - auditors: CurrencyInfo[], - exchanges: CurrencyInfo[], + auditors: CurrencyInfo[]; + exchanges: CurrencyInfo[]; } /** * Get a list of currencies from known auditors and exchanges */ export function listKnownCurrencies(): Promise<ListOfKnownCurrencies> { - return callBackend("listCurrencies", {}).then(result => { - console.log("result list", result) - const auditors = result.trustedAuditors.map((a: Record<string, string>) => ({ - name: a.currency, - baseUrl: a.auditorBaseUrl, - pub: a.auditorPub, - })) - const exchanges = result.trustedExchanges.map((a: Record<string, string>) => ({ - name: a.currency, - baseUrl: a.exchangeBaseUrl, - pub: a.exchangeMasterPub, - })) - return { auditors, exchanges } + return callBackend("listCurrencies", {}).then((result) => { + console.log("result list", result); + const auditors = result.trustedAuditors.map( + (a: Record<string, string>) => ({ + name: a.currency, + baseUrl: a.auditorBaseUrl, + pub: a.auditorPub, + }), + ); + const exchanges = result.trustedExchanges.map( + (a: Record<string, string>) => ({ + name: a.currency, + baseUrl: a.exchangeBaseUrl, + pub: a.exchangeMasterPub, + }), + ); + return { auditors, exchanges }; }); } export function listExchanges(): Promise<ExchangesListRespose> { - return callBackend("listExchanges", {}) + return callBackend("listExchanges", {}); } /** * Get information about the current state of wallet backups. */ export function getBackupInfo(): Promise<BackupInfo> { - return callBackend("getBackupInfo", {}) + return callBackend("getBackupInfo", {}); } /** * Add a backup provider and activate it */ -export function addBackupProvider(backupProviderBaseUrl: string, name: string): Promise<void> { +export function addBackupProvider( + backupProviderBaseUrl: string, + name: string, +): Promise<void> { return callBackend("addBackupProvider", { - backupProviderBaseUrl, activate: true, name - } as AddBackupProviderRequest) + backupProviderBaseUrl, + activate: true, + name, + } as AddBackupProviderRequest); } export function setWalletDeviceId(walletDeviceId: string): Promise<void> { return callBackend("setWalletDeviceId", { - walletDeviceId - } as SetWalletDeviceIdRequest) + walletDeviceId, + } as SetWalletDeviceIdRequest); } export function syncAllProviders(): Promise<void> { - return callBackend("runBackupCycle", {}) + return callBackend("runBackupCycle", {}); } export function syncOneProvider(url: string): Promise<void> { - return callBackend("runBackupCycle", { providers: [url] }) + return callBackend("runBackupCycle", { providers: [url] }); } export function removeProvider(url: string): Promise<void> { - return callBackend("removeBackupProvider", { provider: url } as RemoveBackupProviderRequest) + return callBackend("removeBackupProvider", { + provider: url, + } as RemoveBackupProviderRequest); } export function extendedProvider(url: string): Promise<void> { - return callBackend("extendBackupProvider", { provider: url }) + return callBackend("extendBackupProvider", { provider: url }); } /** * Retry a transaction - * @param transactionId - * @returns + * @param transactionId + * @returns */ export function retryTransaction(transactionId: string): Promise<void> { return callBackend("retryTransaction", { - transactionId + transactionId, } as RetryTransactionRequest); } @@ -229,7 +245,7 @@ export function retryTransaction(transactionId: string): Promise<void> { */ export function deleteTransaction(transactionId: string): Promise<void> { return callBackend("deleteTransaction", { - transactionId + transactionId, } as DeleteTransactionRequest); } @@ -264,29 +280,30 @@ export function acceptWithdrawal( /** * Create a reserve into the exchange that expect the amount indicated - * @param exchangeBaseUrl - * @param amount - * @returns + * @param exchangeBaseUrl + * @param amount + * @returns */ export function acceptManualWithdrawal( exchangeBaseUrl: string, amount: string, ): Promise<AcceptManualWithdrawalResult> { return callBackend("acceptManualWithdrawal", { - amount, exchangeBaseUrl + amount, + exchangeBaseUrl, }); } export function setExchangeTosAccepted( exchangeBaseUrl: string, - etag: string | undefined + etag: string | undefined, ): Promise<void> { return callBackend("setExchangeTosAccepted", { - exchangeBaseUrl, etag - } as AcceptExchangeTosRequest) + exchangeBaseUrl, + etag, + } as AcceptExchangeTosRequest); } - /** * Get diagnostics information */ @@ -319,7 +336,6 @@ export function getWithdrawalDetailsForUri( return callBackend("getWithdrawalDetailsForUri", req); } - /** * Get diagnostics information */ @@ -333,17 +349,15 @@ export function getExchangeTos( acceptedFormat: string[], ): Promise<GetExchangeTosResult> { return callBackend("getExchangeTos", { - exchangeBaseUrl, acceptedFormat + exchangeBaseUrl, + acceptedFormat, }); } -export function addExchange( - req: AddExchangeRequest, -): Promise<void> { +export function addExchange(req: AddExchangeRequest): Promise<void> { return callBackend("addExchange", req); } - export function prepareTip(req: PrepareTipRequest): Promise<PrepareTipResult> { return callBackend("prepareTip", req); } diff --git a/packages/taler-wallet-webextension/static/wallet.html b/packages/taler-wallet-webextension/static/wallet.html index a1c069d74..f9dd8a19b 100644 --- a/packages/taler-wallet-webextension/static/wallet.html +++ b/packages/taler-wallet-webextension/static/wallet.html @@ -2,11 +2,27 @@ <html> <head> <meta charset="utf-8" /> - <link rel="stylesheet" type="text/css" href="/static/style/pure.css" /> - <link rel="stylesheet" type="text/css" href="/static/style/wallet.css" /> <link rel="stylesheet" type="text/css" href="/dist/popupEntryPoint.css" /> <link rel="icon" href="/static/img/icon.png" /> <script src="/dist/walletEntryPoint.js"></script> + <style> + html { + font-family: sans-serif; /* 1 */ + } + h1 { + font-size: 2em; + } + input { + font: inherit; + } + body { + margin: 0; + font-size: 100%; + padding: 0; + background-color: #f8faf7; + font-family: Arial, Helvetica, sans-serif; + } + </style> </head> <body> diff --git a/packages/taler-wallet-webextension/tsconfig.json b/packages/taler-wallet-webextension/tsconfig.json index cff3d8857..25920a120 100644 --- a/packages/taler-wallet-webextension/tsconfig.json +++ b/packages/taler-wallet-webextension/tsconfig.json @@ -1,9 +1,13 @@ { "compilerOptions": { "composite": true, - "lib": ["es6", "DOM"], - "jsx": "react-jsx", - "jsxImportSource": "preact", + "lib": [ + "es6", + "DOM" + ], + "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "jsxFactory": "h", /* Specify the JSX factory function to use when targeting react JSX emit, e.g. React.createElement or h. */ + "jsxFragmentFactory": "Fragment", // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-0.html#custom-jsx-factories "moduleResolution": "Node", "module": "ESNext", "target": "ES6", @@ -16,7 +20,9 @@ "esModuleInterop": true, "importHelpers": true, "rootDir": "./src", - "typeRoots": ["./node_modules/@types"] + "typeRoots": [ + "./node_modules/@types" + ] }, "references": [ { @@ -26,5 +32,7 @@ "path": "../taler-util/" } ], - "include": ["src/**/*"] -} + "include": [ + "src/**/*" + ] +}
\ No newline at end of file |