diff options
author | Sebastian <sebasjm@gmail.com> | 2024-01-11 16:41:24 -0300 |
---|---|---|
committer | Sebastian <sebasjm@gmail.com> | 2024-01-11 16:41:42 -0300 |
commit | 82d4ed90caa4a6ea3bdda1fb80ccecf3dc3637f9 (patch) | |
tree | 59162f0565311e8699ca643a8bd60337ee7f582b /packages/demobank-ui/src | |
parent | ca67640f9f94f1150c0fb67c148dc79daa9d3fa0 (diff) | |
download | wallet-core-82d4ed90caa4a6ea3bdda1fb80ccecf3dc3637f9.tar.xz |
2fa
Diffstat (limited to 'packages/demobank-ui/src')
29 files changed, 1178 insertions, 374 deletions
diff --git a/packages/demobank-ui/src/Routing.tsx b/packages/demobank-ui/src/Routing.tsx index 4a250a0d5..4caa1dff0 100644 --- a/packages/demobank-ui/src/Routing.tsx +++ b/packages/demobank-ui/src/Routing.tsx @@ -39,6 +39,7 @@ import { AccountPage } from "./pages/AccountPage/index.js"; import { useSettingsContext } from "./context/settings.js"; import { useBankCoreApiContext } from "./context/config.js"; import { DownloadStats } from "./pages/DownloadStats.js"; +import { SolveChallengePage } from "./pages/SolveChallengePage.js"; export function Routing(): VNode { const history = createHashHistory(); @@ -75,6 +76,9 @@ export function Routing(): VNode { component={({ wopid }: { wopid: string }) => ( <WithdrawalOperationPage operationId={wopid} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onContinue={() => { route("/account"); }} @@ -113,6 +117,19 @@ export function Routing(): VNode { onContinue={() => { route("/account"); }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} + /> + )} + /> + <Route + path="/2fa" + component={({ }: {}) => ( + <SolveChallengePage + onContinue={() => { + route("/account"); + }} /> )} /> @@ -122,7 +139,7 @@ export function Routing(): VNode { /> <Route path="/download-stats" - component={() => <DownloadStats + component={() => <DownloadStats onCancel={() => { route("/account") }} @@ -149,6 +166,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/account") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onClear={() => { route("/account") }} @@ -165,6 +185,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/account") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onCancel={() => { route("/account") }} @@ -179,6 +202,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/account") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onCancel={() => { route("/account") }} @@ -194,6 +220,9 @@ export function Routing(): VNode { onSelected={(cid) => { route(`/cashout/${cid}`) }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onClose={() => { route("/account") }} @@ -209,6 +238,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onCancel={() => { route("/account") }} @@ -223,6 +255,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/account") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onClear={() => { route("/account") }} @@ -238,6 +273,9 @@ export function Routing(): VNode { onUpdateSuccess={() => { route("/account") }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onCancel={() => { route("/account") }} @@ -253,6 +291,9 @@ export function Routing(): VNode { onSelected={(cid) => { route(`/cashout/${cid}`) }} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onClose={() => { route("/account"); }} @@ -265,8 +306,8 @@ export function Routing(): VNode { component={() => ( <CreateCashout account={username} - onComplete={(cid) => { - route(`/cashout/${cid}`); + onAuthorizationRequired={() => { + route(`/2fa`) }} onCancel={() => { route("/account"); @@ -293,6 +334,9 @@ export function Routing(): VNode { component={({ dest }: { dest: string }) => ( <WireTransfer toAccount={dest} + onAuthorizationRequired={() => { + route(`/2fa`) + }} onCancel={() => { route("/account") }} @@ -308,8 +352,8 @@ export function Routing(): VNode { component={() => { if (isUserAdministrator) { return <AdminHome - onRegister={() => { - route("/register"); + onAuthorizationRequired={() => { + route(`/2fa`) }} onCreateAccount={() => { route("/new-account") @@ -331,6 +375,9 @@ export function Routing(): VNode { } else { return <AccountPage account={username} + onAuthorizationRequired={() => { + route(`/2fa`) + }} goToConfirmOperation={(wopid) => { route(`/operation/${wopid}`); }} diff --git a/packages/demobank-ui/src/components/app.tsx b/packages/demobank-ui/src/components/app.tsx index 4921b6bff..3d1a43803 100644 --- a/packages/demobank-ui/src/components/app.tsx +++ b/packages/demobank-ui/src/components/app.tsx @@ -38,7 +38,7 @@ const App: FunctionalComponent = () => { fetchSettings(setSettings) }, []) if (!settings) return <Loading />; - + const baseUrl = getInitialBackendBaseURL(settings.backendBaseURL); return ( <SettingsProvider value={settings}> @@ -50,6 +50,26 @@ const App: FunctionalComponent = () => { provider: WITH_LOCAL_STORAGE_CACHE ? localStorageProvider : undefined, + // normally, do not revalidate + revalidateOnFocus: false, + revalidateOnReconnect: false, + revalidateIfStale: false, + revalidateOnMount: undefined, + focusThrottleInterval: undefined, + + // normally, do not refresh + refreshInterval: undefined, + dedupingInterval: 2000, + refreshWhenHidden: false, + refreshWhenOffline: false, + + //ignore errors + shouldRetryOnError: false, + errorRetryCount: 0, + errorRetryInterval: undefined, + + // do not go to loading again if already has data + keepPreviousData: true, }} > <Routing /> diff --git a/packages/demobank-ui/src/context/config.ts b/packages/demobank-ui/src/context/config.ts index 2d70cf932..0bf920006 100644 --- a/packages/demobank-ui/src/context/config.ts +++ b/packages/demobank-ui/src/context/config.ts @@ -14,10 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { LibtoolVersion, TalerCorebankApi, TalerCoreBankHttpClient, TalerError } from "@gnu-taler/taler-util"; +import { AccessToken, HttpStatusCode, LibtoolVersion, OperationAlternative, OperationFail, OperationOk, TalerCorebankApi, TalerCoreBankHttpClient, TalerError, TalerErrorCode, UserAndToken } from "@gnu-taler/taler-util"; +import { HttpRequestLibrary } from "@gnu-taler/taler-util/http"; import { BrowserHttpLib, ErrorLoading, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, createContext, FunctionComponent, h, VNode } from "preact"; import { useContext, useEffect, useState } from "preact/hooks"; +import { revalidateAccountDetails, revalidatePublicAccounts, revalidateTransactions } from "../hooks/access.js"; +import { revalidateBusinessAccounts, revalidateCashouts } from "../hooks/circuit.js"; /** * @@ -28,13 +31,14 @@ export type Type = { url: URL, config: TalerCorebankApi.Config, api: TalerCoreBankHttpClient, + hints: VersionHint[] }; const Context = createContext<Type>(undefined as any); export const useBankCoreApiContext = (): Type => useContext(Context); -enum VersionHint { +export enum VersionHint { /** * when this flag is on, server is running an old version with cashout before implementing 2fa API */ @@ -42,7 +46,7 @@ enum VersionHint { } export type ConfigResult = undefined - | { type: "ok", config: TalerCorebankApi.Config, hint: VersionHint[] } + | { type: "ok", config: TalerCorebankApi.Config, hints: VersionHint[] } | { type: "incompatible", result: TalerCorebankApi.Config, supported: string } | { type: "error", error: TalerError } @@ -58,17 +62,17 @@ export const BankCoreApiProvider = ({ const [checked, setChecked] = useState<ConfigResult>() const { i18n } = useTranslationContext(); const url = new URL(baseUrl) - const api = new TalerCoreBankHttpClient(url.href, new BrowserHttpLib()) + const api = new CacheAwareApi(url.href, new BrowserHttpLib()) useEffect(() => { api.getConfig() .then((resp) => { if (api.isCompatible(resp.body.version)) { - setChecked({ type: "ok", config: resp.body, hint: [] }); + setChecked({ type: "ok", config: resp.body, hints: [] }); } else { //this API supports version 3.0.3 const compare = LibtoolVersion.compare("3:0:3", resp.body.version) if (compare?.compatible ?? false) { - setChecked({ type: "ok", config: resp.body, hint: [VersionHint.CASHOUT_BEFORE_2FA] }); + setChecked({ type: "ok", config: resp.body, hints: [VersionHint.CASHOUT_BEFORE_2FA] }); } else { setChecked({ type: "incompatible", result: resp.body, supported: api.PROTOCOL_VERSION }) } @@ -91,7 +95,7 @@ export const BankCoreApiProvider = ({ return h(frameOnError, { children: h("div", {}, i18n.str`the bank backend is not supported. supported version "${checked.supported}", server version "${checked.result.version}"`) }) } const value: Type = { - url, config: checked.config, api + url, config: checked.config, api: api, hints: checked.hints, } return h(Context.Provider, { value, @@ -99,6 +103,59 @@ export const BankCoreApiProvider = ({ }); }; +export class CacheAwareApi extends TalerCoreBankHttpClient { + constructor(baseUrl: string, httpClient?: HttpRequestLibrary) { + super(baseUrl, httpClient) + } + async deleteAccount(auth: UserAndToken, cid?: string | undefined) { + const resp = await super.deleteAccount(auth, cid) + if (resp.type === "ok") { + revalidatePublicAccounts() + revalidateBusinessAccounts() + } + return resp; + } + async createAccount(auth: AccessToken, body: TalerCorebankApi.RegisterAccountRequest) { + const resp = await super.createAccount(auth, body) + if (resp.type === "ok") { + revalidatePublicAccounts() + revalidateBusinessAccounts() + } + return resp; + } + async updateAccount(auth: UserAndToken, body: TalerCorebankApi.AccountReconfiguration, cid?: string | undefined) { + const resp = await super.updateAccount(auth, body, cid) + if (resp.type === "ok") { + revalidateAccountDetails() + } + return resp; + } + async createTransaction(auth: UserAndToken, body: TalerCorebankApi.CreateTransactionRequest, cid?: string | undefined) { + const resp = await super.createTransaction(auth, body, cid) + if (resp.type === "ok") { + revalidateAccountDetails() + revalidateTransactions() + } + return resp; + } + async confirmWithdrawalById(auth: UserAndToken, wid: string, cid?: string | undefined) { + const resp = await super.confirmWithdrawalById(auth, wid, cid) + if (resp.type === "ok") { + revalidateAccountDetails() + revalidateTransactions() + } + return resp; + } + async createCashout(auth: UserAndToken, body: TalerCorebankApi.CashoutRequest, cid?: string | undefined) { + const resp = await super.createCashout(auth, body, cid) + if (resp.type === "ok") { + revalidateAccountDetails() + revalidateCashouts() + } + return resp; + } +} + export const BankCoreApiProviderTesting = ({ children, state, @@ -112,6 +169,7 @@ export const BankCoreApiProviderTesting = ({ url: new URL(url), config: state, api: undefined as any, + hints: [], }; return h(Context.Provider, { diff --git a/packages/demobank-ui/src/hooks/access.ts b/packages/demobank-ui/src/hooks/access.ts index fc1cff129..80ef1874f 100644 --- a/packages/demobank-ui/src/hooks/access.ts +++ b/packages/demobank-ui/src/hooks/access.ts @@ -14,13 +14,13 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { AccessToken, TalerBankIntegrationResultByMethod, TalerCoreBankResultByMethod, TalerHttpError, WithdrawalOperationStatus } from "@gnu-taler/taler-util"; +import { AccessToken, TalerCoreBankResultByMethod, TalerHttpError, WithdrawalOperationStatus } from "@gnu-taler/taler-util"; import { useEffect, useState } from "preact/hooks"; import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useBackendState } from "./backend.js"; // FIX default import https://github.com/microsoft/TypeScript/issues/49189 -import _useSWR, { SWRHook } from "swr"; +import _useSWR, { SWRHook, mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; const useSWR = _useSWR as unknown as SWRHook; @@ -30,6 +30,10 @@ export interface InstanceTemplateFilter { position?: string; } +export function revalidateAccountDetails() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getAccount", undefined, { revalidate: true }) +} + export function useAccountDetails(account: string) { const { state: credentials } = useBackendState(); const { api } = useBankCoreApiContext(); @@ -40,15 +44,6 @@ export function useAccountDetails(account: string) { const token = credentials.status !== "loggedIn" ? undefined : credentials.token const { data, error } = useSWR<TalerCoreBankResultByMethod<"getAccount">, TalerHttpError>( [account, token, "getAccount"], fetcher, { - refreshInterval: 0, - refreshWhenHidden: false, - revalidateOnFocus: false, - revalidateOnReconnect: false, - refreshWhenOffline: false, - errorRetryCount: 0, - errorRetryInterval: 1, - shouldRetryOnError: false, - keepPreviousData: true, }); if (data) return data @@ -56,6 +51,10 @@ export function useAccountDetails(account: string) { return undefined; } +export function revalidateWithdrawalDetails() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getWithdrawalById") +} + export function useWithdrawalDetails(wid: string) { const { api } = useBankCoreApiContext(); const [latestStatus, setLatestStatus] = useState<WithdrawalOperationStatus>() @@ -90,6 +89,9 @@ export function useWithdrawalDetails(wid: string) { return undefined; } +export function revalidateTransactionDetails() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getTransactionById") +} export function useTransactionDetails(account: string, tid: number) { const { state: credentials } = useBackendState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token @@ -117,6 +119,9 @@ export function useTransactionDetails(account: string, tid: number) { return undefined; } +export function revalidatePublicAccounts() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getPublicAccounts") +} export function usePublicAccounts(filterAccount: string | undefined, initial?: number) { const [offset, setOffset] = useState<number | undefined>(initial); const { api } = useBankCoreApiContext(); @@ -171,12 +176,9 @@ export function usePublicAccounts(filterAccount: string | undefined, initial?: n return undefined; } -/** - - * @param account - * @param args - * @returns - */ +export function revalidateTransactions() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getTransactions", undefined, { revalidate: true }) +} export function useTransactions(account: string, initial?: number) { const { state: credentials } = useBackendState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token diff --git a/packages/demobank-ui/src/hooks/backend.ts b/packages/demobank-ui/src/hooks/backend.ts index 863b47bf3..46918ac10 100644 --- a/packages/demobank-ui/src/hooks/backend.ts +++ b/packages/demobank-ui/src/hooks/backend.ts @@ -19,16 +19,15 @@ import { Codec, buildCodecForObject, buildCodecForUnion, - canonicalizeBaseUrl, codecForBoolean, codecForConstString, - codecForString, + codecForString } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { useSWRConfig } from "swr"; +import { mutate } from "swr"; /** * Has the information to reach and @@ -105,7 +104,6 @@ export function useBackendState(): BackendStateHandler { BACKEND_STATE_KEY, defaultState, ); - const mutateAll = useMatchMutate(); return { state, @@ -129,29 +127,11 @@ export function useBackendState(): BackendStateHandler { isUserAdministrator: info.username === "admin", }; update(nextState); - mutateAll(/.*/) + cleanAllCache() }, }; } -export function useMatchMutate(): ( - re: RegExp, - value?: unknown, -) => Promise<any> { - const { cache, mutate } = useSWRConfig(); - - if (!(cache instanceof Map)) { - throw new Error( - "matchMutate requires the cache provider to be a Map instance", - ); - } - - return function matchRegexMutate(re: RegExp, value?: unknown) { - const allKeys = Array.from(cache.keys()); - const keys = allKeys.filter((key) => re.test(key)); - const mutations = keys.map((key) => { - return mutate(key, value, true); - }); - return Promise.all(mutations); - }; +function cleanAllCache(): void { + mutate(() => true, undefined, { revalidate: false }) } diff --git a/packages/demobank-ui/src/hooks/bank-state.ts b/packages/demobank-ui/src/hooks/bank-state.ts index addbbfc0f..99d835c9c 100644 --- a/packages/demobank-ui/src/hooks/bank-state.ts +++ b/packages/demobank-ui/src/hooks/bank-state.ts @@ -15,31 +15,127 @@ */ import { + AbsoluteTime, Codec, + TalerCorebankApi, buildCodecForObject, + buildCodecForUnion, + codecForAbsoluteTime, + codecForAny, + codecForConstString, codecForString, + codecForTanTransmission, codecOptional } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; +export type ChallengeInProgess = + DeleteAccountChallenge | + UpdateAccountChallenge | + UpdatePasswordChallenge | + CreateTransactionChallenge | + ConfirmWithdrawalChallenge | + CashoutChallenge; + +type BaseChallenge<OpType extends string, ReqType> = { + id: string, + operation: OpType, + sent: AbsoluteTime, + info?: TalerCorebankApi.TanTransmission, + request: ReqType +} + +type DeleteAccountChallenge = BaseChallenge<"delete-account", string> +type UpdateAccountChallenge = BaseChallenge<"update-account", TalerCorebankApi.AccountReconfiguration> +type UpdatePasswordChallenge = BaseChallenge<"update-password", TalerCorebankApi.AccountPasswordChange> +type CreateTransactionChallenge = BaseChallenge<"create-transaction", TalerCorebankApi.CreateTransactionRequest> +type ConfirmWithdrawalChallenge = BaseChallenge<"confirm-withdrawal", string> +type CashoutChallenge = BaseChallenge<"create-cashout", TalerCorebankApi.CashoutRequest> + +const codecForChallengeUpdatePassword = (): Codec<UpdatePasswordChallenge> => + buildCodecForObject<UpdatePasswordChallenge>() + .property("operation", codecForConstString("update-password")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForAny()) + .build("UpdatePasswordChallenge"); + +const codecForChallengeDeleteAccount = (): Codec<DeleteAccountChallenge> => + buildCodecForObject<DeleteAccountChallenge>() + .property("operation", codecForConstString("delete-account")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("request", codecForString()) + .property("info", codecOptional(codecForTanTransmission())) + .build("DeleteAccountChallenge"); + +const codecForChallengeUpdateAccount = (): Codec<UpdateAccountChallenge> => + buildCodecForObject<UpdateAccountChallenge>() + .property("operation", codecForConstString("update-account")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForAny()) + .build("UpdateAccountChallenge"); + +const codecForChallengeCreateTransaction = (): Codec<CreateTransactionChallenge> => + buildCodecForObject<CreateTransactionChallenge>() + .property("operation", codecForConstString("create-transaction")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForAny()) + .build("CreateTransactionChallenge"); + +const codecForChallengeConfirmWithdrawal = (): Codec<ConfirmWithdrawalChallenge> => + buildCodecForObject<ConfirmWithdrawalChallenge>() + .property("operation", codecForConstString("confirm-withdrawal")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForString()) + .build("ConfirmWithdrawalChallenge"); + +const codecForChallengeCashout = (): Codec<CashoutChallenge> => + buildCodecForObject<CashoutChallenge>() + .property("operation", codecForConstString("create-cashout")) + .property("id", codecForString()) + .property("sent", codecForAbsoluteTime) + .property("info", codecOptional(codecForTanTransmission())) + .property("request", codecForAny()) + .build("CashoutChallenge"); + +const codecForChallenge = (): Codec<ChallengeInProgess> => + buildCodecForUnion<ChallengeInProgess>() + .discriminateOn("operation") + .alternative("confirm-withdrawal", codecForChallengeConfirmWithdrawal()) + .alternative("create-cashout", codecForChallengeCashout()) + .alternative("create-transaction", codecForChallengeCreateTransaction()) + .alternative("delete-account", codecForChallengeDeleteAccount()) + .alternative("update-account", codecForChallengeUpdateAccount()) + .alternative("update-password", codecForChallengeUpdatePassword()) + .build("ChallengeInProgess"); + + interface BankState { currentWithdrawalOperationId: string | undefined; - currentChallengeId: string | undefined; + currentChallenge: ChallengeInProgess | undefined; } export const codecForBankState = (): Codec<BankState> => buildCodecForObject<BankState>() .property("currentWithdrawalOperationId", codecOptional(codecForString())) - .property("currentChallengeId", codecOptional(codecForString())) + .property("currentChallenge", codecOptional(codecForChallenge())) .build("BankState"); const defaultBankState: BankState = { currentWithdrawalOperationId: undefined, - currentChallengeId: undefined, + currentChallenge: undefined, }; const BANK_STATE_KEY = buildStorageKey( - "bank-state", + "bank-app-state", codecForBankState(), ); diff --git a/packages/demobank-ui/src/hooks/circuit.ts b/packages/demobank-ui/src/hooks/circuit.ts index 8a27f652c..8bff6858d 100644 --- a/packages/demobank-ui/src/hooks/circuit.ts +++ b/packages/demobank-ui/src/hooks/circuit.ts @@ -19,7 +19,7 @@ import { MAX_RESULT_SIZE, PAGE_SIZE } from "../utils.js"; import { useBackendState } from "./backend.js"; import { AccessToken, AmountJson, AmountString, Amounts, OperationOk, TalerBankConversionResultByMethod, TalerCoreBankErrorsByMethod, TalerCoreBankResultByMethod, TalerCorebankApi, TalerError, TalerHttpError, opFixedSuccess } from "@gnu-taler/taler-util"; -import _useSWR, { SWRHook } from "swr"; +import _useSWR, { SWRHook, mutate } from "swr"; import { useBankCoreApiContext } from "../context/config.js"; import { assertUnreachable } from "../pages/WithdrawalOperationPage.js"; import { format, getDate, getDay, getHours, getMonth, getYear, set, sub } from "date-fns"; @@ -42,6 +42,9 @@ type CashoutEstimators = { estimateByDebit: EstimatorFunction; }; +export function revalidateConversionInfo() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getConversionInfoAPI") +} export function useConversionInfo() { const { api, config } = useBankCoreApiContext() @@ -114,6 +117,9 @@ export function useEstimator(): CashoutEstimators { }; } +export function revalidateBusinessAccounts() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getAccounts") +} export function useBusinessAccounts() { const { state: credentials } = useBackendState(); const token = credentials.status !== "loggedIn" ? undefined : credentials.token @@ -174,6 +180,9 @@ type CashoutWithId = TalerCorebankApi.CashoutStatusResponse & { id: number } function notUndefined(c: CashoutWithId | undefined): c is CashoutWithId { return c !== undefined } +export function revalidateOnePendingCashouts() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "useOnePendingCashouts") +} export function useOnePendingCashouts(account: string) { const { state: credentials } = useBackendState(); const { api, config } = useBankCoreApiContext(); @@ -211,6 +220,9 @@ export function useOnePendingCashouts(account: string) { return undefined; } +export function revalidateCashouts() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "useCashouts") +} export function useCashouts(account: string) { const { state: credentials } = useBackendState(); const { api, config } = useBankCoreApiContext(); @@ -251,6 +263,9 @@ export function useCashouts(account: string) { return undefined; } +export function revalidateCashoutDetails() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "getCashoutById") +} export function useCashoutDetails(cashoutId: number | undefined) { const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials @@ -284,6 +299,9 @@ export type MonitorMetrics = { } export type LastMonitor = { current: TalerCoreBankResultByMethod<"getMonitor">, previous: TalerCoreBankResultByMethod<"getMonitor"> } +export function revalidateLastMonitorInfo() { + mutate(key => Array.isArray(key) && key[key.length - 1] === "useLastMonitorInfo") +} export function useLastMonitorInfo(currentMoment: number, previousMoment: number, timeframe: TalerCorebankApi.MonitorTimeframeParam) { const { api, config } = useBankCoreApiContext(); const { state: credentials } = useBackendState(); diff --git a/packages/demobank-ui/src/pages/AccountPage/index.ts b/packages/demobank-ui/src/pages/AccountPage/index.ts index 7261af69a..cfe184612 100644 --- a/packages/demobank-ui/src/pages/AccountPage/index.ts +++ b/packages/demobank-ui/src/pages/AccountPage/index.ts @@ -23,6 +23,7 @@ import { InvalidIbanView, ReadyView } from "./views.js"; export interface Props { account: string; + onAuthorizationRequired: () => void; goToConfirmOperation: (id: string) => void; } @@ -48,6 +49,7 @@ export namespace State { error: undefined; account: string, limit: AmountJson, + onAuthorizationRequired: () => void; goToConfirmOperation: (id: string) => void; } diff --git a/packages/demobank-ui/src/pages/AccountPage/state.ts b/packages/demobank-ui/src/pages/AccountPage/state.ts index 88e8cf747..38b4d9f36 100644 --- a/packages/demobank-ui/src/pages/AccountPage/state.ts +++ b/packages/demobank-ui/src/pages/AccountPage/state.ts @@ -20,7 +20,7 @@ import { useAccountDetails } from "../../hooks/access.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { Props, State } from "./index.js"; -export function useComponentState({ account, goToConfirmOperation }: Props): State { +export function useComponentState({ account, goToConfirmOperation, onAuthorizationRequired }: Props): State { const result = useAccountDetails(account); const { i18n } = useTranslationContext(); @@ -78,6 +78,7 @@ export function useComponentState({ account, goToConfirmOperation }: Props): Sta status: "ready", goToConfirmOperation, error: undefined, + onAuthorizationRequired, account, limit, }; diff --git a/packages/demobank-ui/src/pages/AccountPage/views.tsx b/packages/demobank-ui/src/pages/AccountPage/views.tsx index d760543c6..59a6db7b9 100644 --- a/packages/demobank-ui/src/pages/AccountPage/views.tsx +++ b/packages/demobank-ui/src/pages/AccountPage/views.tsx @@ -14,15 +14,15 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { TalerError, TranslatedString } from "@gnu-taler/taler-util"; +import { Attention, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { Attention } from "@gnu-taler/web-util/browser"; import { Transactions } from "../../components/Transactions/index.js"; +import { useBankState } from "../../hooks/bank-state.js"; +import { useOnePendingCashouts } from "../../hooks/circuit.js"; import { usePreferences } from "../../hooks/preferences.js"; import { PaymentOptions } from "../PaymentOptions.js"; import { State } from "./index.js"; -import { useCashouts, useOnePendingCashouts } from "../../hooks/circuit.js"; -import { TalerError } from "@gnu-taler/taler-util"; export function InvalidIbanView({ error }: State.InvalidIban) { return ( @@ -55,27 +55,35 @@ function ShowDemoInfo(): VNode { </Attention> } -export function ReadyView({ account, limit, goToConfirmOperation }: State.Ready): VNode<{}> { +function ShowPedingOperation(): VNode { + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + if (!bankState.currentChallenge) return <Fragment />; + const title = ((op): TranslatedString => { + switch (op) { + case "delete-account": return i18n.str`Pending account delete operation` + case "update-account": return i18n.str`Pending account update operation` + case "update-password": return i18n.str`Pending password update operation` + case "create-transaction": return i18n.str`Pending transaction operation` + case "confirm-withdrawal": return i18n.str`Pending withdrawal operation` + case "create-cashout": return i18n.str`Pending cashout operation` + } + })(bankState.currentChallenge.operation) + return <Attention title={title} type="warning" onClose={() => { updateBankState("currentChallenge", undefined); }}> + <i18n.Translate> + To complete or cancel the operation click <a class="font-semibold text-yellow-700 hover:text-yellow-600" href={`#/2fa`}>here</a> + </i18n.Translate> + </Attention> +} + +export function ReadyView({ account, limit, goToConfirmOperation, onAuthorizationRequired }: State.Ready): VNode<{}> { return <Fragment> + <ShowPedingOperation /> <ShowDemoInfo /> - <PendingCashouts account={account}/> - <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} /> + <PaymentOptions limit={limit} goToConfirmOperation={goToConfirmOperation} onAuthorizationRequired={onAuthorizationRequired} /> <Transactions account={account} /> </Fragment>; } -function PendingCashouts({account}: {account: string}):VNode { - const { i18n } = useTranslationContext(); - const result = useOnePendingCashouts(account) - if (!result || result instanceof TalerError || result.type !== "ok" || !result.body) { - return <Fragment /> - } - - return <Attention title={i18n.str`You have pending cashout operation to complete`} > - <i18n.Translate> - Cashout with subject "{result.body.subject}", look for the code and complete the operation <a target="_blank" rel="noreferrer noopener" class="font-semibold text-blue-700 hover:text-blue-600" href={`#/cashout/${result.body.id}`}>here</a>. - </i18n.Translate> - </Attention> -}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/OperationState/index.ts b/packages/demobank-ui/src/pages/OperationState/index.ts index e3aec21c5..53d07e44b 100644 --- a/packages/demobank-ui/src/pages/OperationState/index.ts +++ b/packages/demobank-ui/src/pages/OperationState/index.ts @@ -22,6 +22,7 @@ import { AbortedView, ConfirmedView, FailedView, InvalidPaytoView, InvalidReserv export interface Props { currency: string; + onAuthorizationRequired: () => void, onClose: () => void; } @@ -82,11 +83,12 @@ export namespace State { } export interface NeedConfirmation { status: "need-confirmation", + onAuthorizationRequired: () => void, account: string, onAbort: undefined | (() => Promise<TalerCoreBankErrorsByMethod<"abortWithdrawalById"> | undefined>); onConfirm: undefined | (() => Promise<TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined>); error: undefined; - busy: boolean, + id: string, } export interface Aborted { status: "aborted", diff --git a/packages/demobank-ui/src/pages/OperationState/state.ts b/packages/demobank-ui/src/pages/OperationState/state.ts index b214a400d..fbf43867f 100644 --- a/packages/demobank-ui/src/pages/OperationState/state.ts +++ b/packages/demobank-ui/src/pages/OperationState/state.ts @@ -14,26 +14,25 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { Amounts, FailCasesByMethod, HttpStatusCode, TalerCoreBankErrorsByMethod, TalerError, TalerErrorDetail, TranslatedString, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; -import { notify, notifyError, notifyInfo, useTranslationContext, utils } from "@gnu-taler/web-util/browser"; +import { Amounts, HttpStatusCode, TalerCoreBankErrorsByMethod, TalerError, parsePaytoUri, parseWithdrawUri, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { utils } from "@gnu-taler/web-util/browser"; import { useEffect, useState } from "preact/hooks"; import { mutate } from "swr"; import { useBankCoreApiContext } from "../../context/config.js"; import { useWithdrawalDetails } from "../../hooks/access.js"; import { useBackendState } from "../../hooks/backend.js"; +import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { Props, State } from "./index.js"; -import { useBankState } from "../../hooks/bank-state.js"; -export function useComponentState({ currency, onClose }: Props): utils.RecursiveState<State> { +export function useComponentState({ currency, onClose, onAuthorizationRequired, }: Props): utils.RecursiveState<State> { const [settings] = usePreferences() const [bankState, updateBankState] = useBankState(); const { state: credentials } = useBackendState() const creds = credentials.status !== "loggedIn" ? undefined : credentials const { api } = useBankCoreApiContext() - const [busy, setBusy] = useState<Record<string, undefined>>() const [failure, setFailure] = useState<TalerCoreBankErrorsByMethod<"createWithdrawal"> | undefined>() const amount = settings.maxWithdrawalAmount @@ -88,9 +87,7 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive async function doConfirm(): Promise<TalerCoreBankErrorsByMethod<"confirmWithdrawalById"> | undefined> { if (!creds) return; - setBusy({}) const resp = await api.confirmWithdrawalById(creds, wid); - setBusy(undefined) if (resp.type === "ok") { mutate(() => true)//clean withdrawal state } else { @@ -213,9 +210,10 @@ export function useComponentState({ currency, onClose }: Props): utils.Recursive return { status: "need-confirmation", error: undefined, + onAuthorizationRequired, account: data.username, + id: withdrawalOperationId, onAbort: !creds ? undefined : doAbort, - busy: !!busy, onConfirm: !creds ? undefined : doConfirm } } diff --git a/packages/demobank-ui/src/pages/OperationState/views.tsx b/packages/demobank-ui/src/pages/OperationState/views.tsx index 5ebd66dac..0ebdeea47 100644 --- a/packages/demobank-ui/src/pages/OperationState/views.tsx +++ b/packages/demobank-ui/src/pages/OperationState/views.tsx @@ -14,17 +14,16 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, TalerErrorCode, TranslatedString, stringifyWithdrawUri } from "@gnu-taler/taler-util"; -import { Attention, LocalNotificationBanner, ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { AbsoluteTime, HttpStatusCode, TalerErrorCode, TranslatedString, stringifyWithdrawUri } from "@gnu-taler/taler-util"; +import { Attention, LocalNotificationBanner, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useEffect, useMemo, useState } from "preact/hooks"; +import { useEffect } from "preact/hooks"; import { QR } from "../../components/QR.js"; +import { useBankState } from "../../hooks/bank-state.js"; import { usePreferences } from "../../hooks/preferences.js"; -import { undefinedIfEmpty } from "../../utils.js"; import { ShouldBeSameUser } from "../WithdrawalConfirmationQuestion.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { State } from "./index.js"; -import { useBankState } from "../../hooks/bank-state.js"; export function InvalidPaytoView({ payto, onClose }: State.InvalidPayto) { return ( @@ -42,30 +41,12 @@ export function InvalidReserveView({ reserve, onClose }: State.InvalidReserve) { ); } -export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doConfirm, busy, account }: State.NeedConfirmation) { +export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doConfirm, account, id, onAuthorizationRequired, }: State.NeedConfirmation) { const { i18n } = useTranslationContext() const [settings] = usePreferences() const [notification, notify, errorHandler] = useLocalNotification() const [, updateBankState] = useBankState() - const captchaNumbers = useMemo(() => { - return { - a: Math.floor(Math.random() * 10), - b: Math.floor(Math.random() * 10), - }; - }, []); - const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>(); - const answer = parseInt(captchaAnswer ?? "", 10); - const errors = undefinedIfEmpty({ - answer: !captchaAnswer - ? i18n.str`Answer the question before continue` - : Number.isNaN(answer) - ? i18n.str`The answer should be a number` - : answer !== captchaNumbers.a + captchaNumbers.b - ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.` - : undefined, - }) ?? (busy ? {} as Record<string, undefined> : undefined); - async function onCancel() { errorHandler(async () => { if (!doAbort) return; @@ -137,11 +118,13 @@ export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doCon debug: resp.detail, }); case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`The operation needs a confirmation to complete.`, - }); + updateBankState("currentChallenge", { + operation: "confirm-withdrawal", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request: id, + }) + return onAuthorizationRequired() } default: assertUnreachable(resp) } @@ -165,35 +148,6 @@ export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doCon e.preventDefault() }} > - <div class="px-4 py-6 sm:p-8"> - <label for="withdraw-amount">{i18n.str`What is`} - <em> - {captchaNumbers.a} + {captchaNumbers.b} - </em> - ? - </label> - <div class="mt-2"> - <div class="relative rounded-md shadow-sm"> - <input - type="text" - // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - aria-describedby="answer" - autoFocus - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={captchaAnswer ?? ""} - required - - name="answer" - id="answer" - autocomplete="off" - onChange={(e): void => { - setCaptchaAnswer(e.currentTarget.value) - }} - /> - </div> - <ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} /> - </div> - </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> <button type="button" class="text-sm font-semibold leading-6 text-gray-900" onClick={(e) => { @@ -204,7 +158,6 @@ export function NeedConfirmationView({ error, onAbort: doAbort, onConfirm: doCon <i18n.Translate>Cancel</i18n.Translate></button> <button type="submit" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors} onClick={(e) => { e.preventDefault() onConfirm() diff --git a/packages/demobank-ui/src/pages/PaymentOptions.tsx b/packages/demobank-ui/src/pages/PaymentOptions.tsx index 1a431a939..06d293097 100644 --- a/packages/demobank-ui/src/pages/PaymentOptions.tsx +++ b/packages/demobank-ui/src/pages/PaymentOptions.tsx @@ -16,18 +16,21 @@ import { AmountJson } from "@gnu-taler/taler-util"; import { notifyInfo, useTranslationContext } from "@gnu-taler/web-util/browser"; -import { h, VNode } from "preact"; +import { VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { PaytoWireTransferForm, doAutoFocus } from "./PaytoWireTransferForm.js"; -import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; -import { usePreferences } from "../hooks/preferences.js"; import { useBankState } from "../hooks/bank-state.js"; +import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; +import { WalletWithdrawForm } from "./WalletWithdrawForm.js"; /** * Let the user choose a payment option, * then specify the details trigger the action. */ -export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJson, goToConfirmOperation: (id: string) => void }): VNode { +export function PaymentOptions({ limit, goToConfirmOperation, onAuthorizationRequired }: { + limit: AmountJson, + onAuthorizationRequired: () => void, + goToConfirmOperation: (id: string) => void, +}): VNode { const { i18n } = useTranslationContext(); const [bankState] = useBankState(); @@ -96,6 +99,7 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ <WalletWithdrawForm focus limit={limit} + onAuthorizationRequired={onAuthorizationRequired} goToConfirmOperation={goToConfirmOperation} onCancel={() => { setTab(undefined) @@ -107,6 +111,7 @@ export function PaymentOptions({ limit, goToConfirmOperation }: { limit: AmountJ focus title={i18n.str`Transfer details`} limit={limit} + onAuthorizationRequired={onAuthorizationRequired} onSuccess={() => { notifyInfo(i18n.str`Wire transfer created!`); setTab(undefined) diff --git a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx index 0c6c9ada2..f7b81be48 100644 --- a/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx +++ b/packages/demobank-ui/src/pages/PaytoWireTransferForm.tsx @@ -15,6 +15,7 @@ */ import { + AbsoluteTime, AmountJson, AmountLike, AmountString, @@ -57,12 +58,14 @@ export function PaytoWireTransferForm({ toAccount, onSuccess, onCancel, + onAuthorizationRequired, limit, }: { title: TranslatedString, focus?: boolean; toAccount?: string, onSuccess: () => void; + onAuthorizationRequired: () => void; onCancel: (() => void) | undefined; limit: AmountJson; }): VNode { @@ -146,12 +149,14 @@ export function PaytoWireTransferForm({ sendingAmount = `${limit.currency}:${trimmedAmountStr}` as AmountString } const puri = payto_uri; + const sAmount = sendingAmount; await handleError(async () => { - const resp = await api.createTransaction(credentials, { + const request = { payto_uri: puri, - amount: sendingAmount, - }); + amount: sAmount, + } + const resp = await api.createTransaction(credentials, request); mutate(() => true) if (resp.type === "fail") { switch (resp.case) { @@ -192,11 +197,13 @@ export function PaytoWireTransferForm({ debug: resp.detail, }) case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`The operation needs a confirmation to complete.`, - }); + updateBankState("currentChallenge", { + operation: "create-transaction", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request, + }) + return onAuthorizationRequired() } default: assertUnreachable(resp) } diff --git a/packages/demobank-ui/src/pages/SolveChallengePage.tsx b/packages/demobank-ui/src/pages/SolveChallengePage.tsx new file mode 100644 index 000000000..e55038df5 --- /dev/null +++ b/packages/demobank-ui/src/pages/SolveChallengePage.tsx @@ -0,0 +1,553 @@ +/* + This file is part of GNU Taler + (C) 2022 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + HttpStatusCode, + Logger, + TalerCorebankApi, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + parsePaytoUri +} from "@gnu-taler/taler-util"; +import { + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; +import { useBankCoreApiContext } from "../context/config.js"; +import { useWithdrawalDetails } from "../hooks/access.js"; +import { useBackendState } from "../hooks/backend.js"; +import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; +import { useConversionInfo } from "../hooks/circuit.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { OperationNotFound } from "./WithdrawalQRCode.js"; + +const logger = new Logger("SolveChallenge"); + +export function SolveChallengePage({ + onContinue, +}: { + onContinue: () => void; +}): VNode { + const { api } = useBankCoreApiContext() + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + const [code, setCode] = useState<string | undefined>(undefined); + const [notification, notify, handleError] = useLocalNotification() + const { state } = useBackendState(); + const creds = state.status !== "loggedIn" ? undefined : state + + if (!bankState.currentChallenge) { + return <div> + <span>no challenge to solve </span> + <button type="button" + class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" + onClick={() => { + onContinue() + }} + > + <i18n.Translate>Continue</i18n.Translate> + </button> + </div> + } + + const ch = bankState.currentChallenge + const errors = undefinedIfEmpty({ + code: !code ? i18n.str`required` : undefined, + }); + + async function startChallenge() { + if (!creds) return; + await handleError(async () => { + const resp = await api.sendChallenge(creds, ch.id); + if (resp.type === "ok") { + const newCh = structuredClone(ch) + newCh.sent = AbsoluteTime.now() + newCh.info = resp.body + updateBankState("currentChallenge", newCh) + } else { + switch (resp.case) { + case HttpStatusCode.NotFound: return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case HttpStatusCode.Unauthorized: return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case TalerErrorCode.BANK_TAN_CHANNEL_SCRIPT_FAILED: return notify({ + type: "error", + title: i18n.str`Cashout not found. It may be also mean that it was already aborted.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } + }) + } + + async function completeChallenge() { + if (!creds || !code) return; + await handleError(async () => { + { + const resp = await api.confirmChallenge(creds, ch.id, { + tan: code + }); + if (resp.type === "fail") { + setCode("") + switch (resp.case) { + case HttpStatusCode.NotFound: return notify({ + type: "error", + title: i18n.str`Challenge not found.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case HttpStatusCode.Unauthorized: return notify({ + type: "error", + title: i18n.str`This user is not authorized to complete this challenge.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case HttpStatusCode.TooManyRequests: return notify({ + type: "error", + title: i18n.str`Too many attemps, try another code.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case TalerErrorCode.BANK_TAN_CHALLENGE_FAILED: return notify({ + type: "error", + title: i18n.str`The confirmation code is wrong, try again.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + case TalerErrorCode.BANK_TAN_CHALLENGE_EXPIRED: return notify({ + type: "error", + title: i18n.str`The operation expired.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + default: assertUnreachable(resp) + } + } + } + { + const resp = await (async (ch: ChallengeInProgess) => { + switch (ch.operation) { + case "delete-account": return await api.deleteAccount(creds, ch.id) + case "update-account": return await api.updateAccount(creds, ch.request, ch.id) + case "update-password": return await api.updatePassword(creds, ch.request, ch.id) + case "create-transaction": return await api.createTransaction(creds, ch.request, ch.id) + case "confirm-withdrawal": return await api.confirmWithdrawalById(creds, ch.request, ch.id) + case "create-cashout": return await api.createCashout(creds, ch.request, ch.id) + default: assertUnreachable(ch) + } + })(ch); + + if (resp.type === "fail") { + if (resp.case !== HttpStatusCode.Accepted) { + return notify({ + type: "error", + title: i18n.str`The operation failed.`, + description: resp.detail.hint as TranslatedString, + debug: resp.detail, + }) + } + // another challenge required + updateBankState("currentChallenge", { + operation: ch.operation, + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request: ch.request as any, + }) + return notify({ + type: "info", + title: i18n.str`The operation needs another confirmation to complete.`, + }) + } + updateBankState("currentChallenge", undefined) + return onContinue() + } + }) + } + + const subtitle = ((op): TranslatedString => { + switch (op) { + case "delete-account": return i18n.str`Account delete` + case "update-account": return i18n.str`Account update` + case "update-password": return i18n.str`Password update` + case "create-transaction": return i18n.str`Wire transfer` + case "confirm-withdrawal": return i18n.str`Withdrawal` + case "create-cashout": return i18n.str`Cashout` + } + })(ch.operation) + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> + <div class="px-4 sm:px-0"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span class="text-sm text-black font-semibold leading-6 " id="availability-label"> + <i18n.Translate>Confirm the operation</i18n.Translate> + </span> + </h2> + <span> + {subtitle} + </span> + </div> + + <div class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2"> + <ChallengeDetails challenge={bankState.currentChallenge} onStart={startChallenge} /> + {ch.info && + <div class="mt-3 text-sm leading-6"> + <form + class="bg-white shadow-sm ring-1 ring-gray-900/5" + autoCapitalize="none" + autoCorrect="off" + onSubmit={e => { + e.preventDefault() + }} + > + <div class="px-4 py-6 sm:p-8"> + <label for="withdraw-amount"> + <i18n.Translate>Enter the confirmation code</i18n.Translate> + </label> + <div class="mt-2"> + <div class="relative rounded-md shadow-sm"> + <input + type="text" + // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + aria-describedby="answer" + autoFocus + class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + value={code ?? ""} + required + + name="answer" + id="answer" + autocomplete="off" + onChange={(e): void => { + setCode(e.currentTarget.value) + }} + /> + </div> + <ShowInputErrorLabel message={errors?.code} isDirty={code !== undefined} /> + </div> + </div> + <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> + <button type="button" + class="inline-flex items-center rounded-md bg-red-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500" + onClick={() => { + updateBankState("currentChallenge", undefined) + onContinue() + }} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + <button type="submit" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + disabled={!!errors} + onClick={(e) => { + completeChallenge() + }} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </form> + + {/* <ShouldBeSameUser username={details.username}> */} + {/* </ShouldBeSameUser> */} + </div> + } + </div> + </div> + </Fragment> + + ); +} + +function ChallengeDetails({ challenge, onStart }: { challenge: ChallengeInProgess, onStart: () => void }): VNode { + const { i18n } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + return <div class="px-4 mt-4 "> + <div class="w-full"> + <div class="flex justify-center"> + + {challenge.info ? + <button type="submit" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={(e) => { + onStart() + }} + > + <i18n.Translate>Send again</i18n.Translate> + </button> + : + <button type="submit" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={(e) => { + onStart() + }} + > + <i18n.Translate>Send code</i18n.Translate> + </button> + } + </div> + <div class="mt-6 border-t border-gray-100"> + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span class="text-sm text-black font-semibold leading-6 " id="availability-label"> + <i18n.Translate>Operation details</i18n.Translate> + </span> + </h2> + <dl class="divide-y divide-gray-100"> + {((): VNode => { + switch (challenge.operation) { + case "delete-account": return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{challenge.request}</dd> + </div> + case "create-transaction": { + const payto = parsePaytoUri(challenge.request.payto_uri)! + return <Fragment> + {challenge.request.amount && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={Amounts.parseOrThrow(challenge.request.amount)} spec={config.currency_specification} /> + </dd> + </div> + } + {payto.isKnown && payto.targetType === "iban" && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">To account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {payto.iban} + </dd> + </div> + } + </Fragment> + } + case "confirm-withdrawal": return <ShowWithdrawalDetails id={challenge.request} /> + case "create-cashout": { + return <ShowCashoutDetails request={challenge.request} /> + } + case "update-account": { + return <Fragment> + {challenge.request.cashout_payto_uri !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Cashout account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.cashout_payto_uri} + </dd> + </div> + } + {challenge.request.contact_data?.email !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Email</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.contact_data?.email} + </dd> + </div> + } + {challenge.request.contact_data?.phone !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Phone</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.contact_data?.phone} + </dd> + </div> + } + {challenge.request.debit_threshold !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Debit threshold</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={Amounts.parseOrThrow(challenge.request.debit_threshold)} spec={config.currency_specification} /> + </dd> + </div> + } + {challenge.request.is_public !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Is public</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.is_public ? "enable" : "disable"} + </dd> + </div> + } + {challenge.request.name !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Name</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.name} + </dd> + </div> + } + {challenge.request.tan_channel !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Authentication channel</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.tan_channel} + </dd> + </div> + } + </Fragment> + } + case "update-password": { + return <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">New password</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.new_password} + </dd> + </div> + </Fragment> + } + default: assertUnreachable(challenge) + } + })()} + + {challenge.info && + <h2 class="text-base font-semibold leading-7 text-gray-900"> + <span class="text-sm text-black font-semibold leading-6 " id="availability-label"> + <i18n.Translate>Challenge details</i18n.Translate> + </span> + </h2> + } + {challenge.sent.t_ms !== "never" && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Sent at</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {format(challenge.sent.t_ms, "dd/MM/yyyy HH:mm:ss")} + </dd> + </div> + } + {challenge.info && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900"> + {((ch: TalerCorebankApi.TanChannel): VNode => { + switch (ch) { + case TalerCorebankApi.TanChannel.SMS: return <i18n.Translate>To phone</i18n.Translate> + case TalerCorebankApi.TanChannel.EMAIL: return <i18n.Translate>To email</i18n.Translate> + default: assertUnreachable(ch) + } + })(challenge.info.tan_channel)} + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.info.tan_info} + </dd> + </div> + } + + </dl> + </div> + </div> + </div> +} + +function ShowWithdrawalDetails({ id }: { id: string }): VNode { + const { i18n } = useTranslationContext(); + const details = useWithdrawalDetails(id) + const { config } = useBankCoreApiContext(); + if (!details) { + return <Loading /> + } + if (details instanceof TalerError) { + return <ErrorLoadingWithDebug error={details} /> + } + if (details.type === "fail") { + switch (details.case) { + case HttpStatusCode.BadRequest: + case HttpStatusCode.NotFound: return <OperationNotFound onClose={undefined} /> + default: assertUnreachable(details) + } + } + + return <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={Amounts.parseOrThrow(details.body.amount)} spec={config.currency_specification} /> + </dd> + </div> + {details.body.selected_reserve_pub !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Withdraw id</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0" title={details.body.selected_reserve_pub}> + {details.body.selected_reserve_pub.substring(0, 16)}... + </dd> + </div> + } + {details.body.selected_exchange_account !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">To account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {details.body.selected_exchange_account} + </dd> + </div> + } + </Fragment> +} + +function ShowCashoutDetails({ request }: { request: TalerCorebankApi.CashoutRequest }): VNode { + const { i18n } = useTranslationContext(); + const info = useConversionInfo(); + if (!info) { + return <Loading /> + } + + if (info instanceof TalerError) { + return <ErrorLoadingWithDebug error={info} /> + } + return <Fragment> + {request.subject !== undefined && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Subject</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {request.subject} + </dd> + </div> + } + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Debit</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={Amounts.parseOrThrow(request.amount_credit)} spec={info.body.regional_currency_specification} /> + </dd> + </div> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Credit</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={Amounts.parseOrThrow(request.amount_credit)} spec={info.body.fiat_currency_specification} /> + </dd> + </div> + </Fragment> +}
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx index 6e13ae657..c04e85e0c 100644 --- a/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx +++ b/packages/demobank-ui/src/pages/WalletWithdrawForm.tsx @@ -235,10 +235,12 @@ export function WalletWithdrawForm({ focus, limit, onCancel, + onAuthorizationRequired, goToConfirmOperation, }: { limit: AmountJson; focus?: boolean; + onAuthorizationRequired: () => void, goToConfirmOperation: (operationId: string) => void; onCancel: () => void; }): VNode { @@ -274,6 +276,7 @@ export function WalletWithdrawForm({ : <OperationState currency={limit.currency} + onAuthorizationRequired={onAuthorizationRequired} onClose={onCancel} /> } diff --git a/packages/demobank-ui/src/pages/WireTransfer.tsx b/packages/demobank-ui/src/pages/WireTransfer.tsx index d6133b504..25d43a832 100644 --- a/packages/demobank-ui/src/pages/WireTransfer.tsx +++ b/packages/demobank-ui/src/pages/WireTransfer.tsx @@ -8,7 +8,12 @@ import { LoginForm } from "./LoginForm.js"; import { PaytoWireTransferForm } from "./PaytoWireTransferForm.js"; import { assertUnreachable } from "./WithdrawalOperationPage.js"; -export function WireTransfer({ toAccount, onRegister, onCancel, onSuccess }: { onSuccess?: () => void; toAccount?: string, onCancel?: () => void, onRegister?: () => void }): VNode { +export function WireTransfer({ toAccount, onAuthorizationRequired, onCancel, onSuccess }: { + onSuccess?: () => void; + toAccount?: string, + onCancel?: () => void, + onAuthorizationRequired: () => void, +}): VNode { const { i18n } = useTranslationContext(); const r = useBackendState(); const account = r.state.status !== "loggedOut" ? r.state.username : "admin"; @@ -42,6 +47,7 @@ export function WireTransfer({ toAccount, onRegister, onCancel, onSuccess }: { o title={i18n.str`Make a wire transfer`} toAccount={toAccount} limit={limit} + onAuthorizationRequired={onAuthorizationRequired} onSuccess={() => { notifyInfo(i18n.str`Wire transfer created!`); if (onSuccess) onSuccess() diff --git a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx index 206b51008..890478f82 100644 --- a/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalConfirmationQuestion.tsx @@ -15,40 +15,33 @@ */ import { + AbsoluteTime, AmountJson, HttpStatusCode, Logger, PaytoUri, PaytoUriIBAN, PaytoUriTalerBank, - TalerError, TalerErrorCode, TranslatedString, WithdrawUriResult } from "@gnu-taler/taler-util"; import { Attention, - Loading, LocalNotificationBanner, - ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; -import { useMemo, useState } from "preact/hooks"; import { mutate } from "swr"; -import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { useBankCoreApiContext } from "../context/config.js"; -import { useWithdrawalDetails } from "../hooks/access.js"; import { useBackendState } from "../hooks/backend.js"; +import { useBankState } from "../hooks/bank-state.js"; import { usePreferences } from "../hooks/preferences.js"; -import { undefinedIfEmpty } from "../utils.js"; import { LoginForm } from "./LoginForm.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { assertUnreachable } from "./WithdrawalOperationPage.js"; -import { OperationNotFound } from "./WithdrawalQRCode.js"; -import { useBankState } from "../hooks/bank-state.js"; const logger = new Logger("WithdrawalConfirmationQuestion"); @@ -60,7 +53,8 @@ interface Props { reserve: string, username: string, amount: AmountJson, - } + }, + onAuthorizationRequired: () => void, } /** * Additional authentication required to complete the operation. @@ -69,52 +63,20 @@ interface Props { export function WithdrawalConfirmationQuestion({ onAborted, details, + onAuthorizationRequired, withdrawUri, }: Props): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreferences() const { state: credentials } = useBackendState(); const creds = credentials.status !== "loggedIn" ? undefined : credentials - const withdrawalInfo = useWithdrawalDetails(withdrawUri.withdrawalOperationId) const [, updateBankState] = useBankState() - if (!withdrawalInfo) { - return <Loading /> - } - if (withdrawalInfo instanceof TalerError) { - return <ErrorLoadingWithDebug error={withdrawalInfo} /> - } - if (withdrawalInfo.type === "fail") { - switch (withdrawalInfo.case) { - case HttpStatusCode.NotFound: return <OperationNotFound onClose={onAborted} /> - case HttpStatusCode.BadRequest: return <OperationNotFound onClose={onAborted} /> - default: assertUnreachable(withdrawalInfo) - } - } - const captchaNumbers = useMemo(() => { - return { - a: Math.floor(Math.random() * 10), - b: Math.floor(Math.random() * 10), - }; - }, []); const [notification, notify, handleError] = useLocalNotification() const { config, api } = useBankCoreApiContext() - const [captchaAnswer, setCaptchaAnswer] = useState<string | undefined>(); - const answer = parseInt(captchaAnswer ?? "", 10); - const [busy, setBusy] = useState<Record<string, undefined>>() - const errors = undefinedIfEmpty({ - answer: !captchaAnswer - ? i18n.str`Answer the question before continue` - : Number.isNaN(answer) - ? i18n.str`The answer should be a number` - : answer !== captchaNumbers.a + captchaNumbers.b - ? i18n.str`The answer "${answer}" to "${captchaNumbers.a} + ${captchaNumbers.b}" is wrong.` - : undefined, - }) ?? busy; async function doTransfer() { - setBusy({}) await handleError(async () => { if (!creds) return; const resp = await api.confirmWithdrawalById(creds, withdrawUri.withdrawalOperationId); @@ -156,21 +118,21 @@ export function WithdrawalConfirmationQuestion({ debug: resp.detail, }) case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`The operation needs a confirmation to complete.`, - }); + updateBankState("currentChallenge", { + operation: "confirm-withdrawal", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request: withdrawUri.withdrawalOperationId, + }) + return onAuthorizationRequired() } default: assertUnreachable(resp) } } }) - setBusy(undefined) } async function doCancel() { - setBusy({}) await handleError(async () => { if (!creds) return; const resp = await api.abortWithdrawalById(creds, withdrawUri.withdrawalOperationId); @@ -200,7 +162,6 @@ export function WithdrawalConfirmationQuestion({ } } }) - setBusy(undefined) } return ( @@ -215,10 +176,7 @@ export function WithdrawalConfirmationQuestion({ <div class="mt-3 text-sm leading-6"> <ShouldBeSameUser username={details.username}> - <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-3 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> - <div class="px-4 sm:px-0"> - <h2 class="text-base font-semibold text-gray-900"><i18n.Translate>Answer the next question to authorize the wire transfer.</i18n.Translate></h2> - </div> + <div class="grid grid-cols-1 gap-x-8 gap-y-8 pt-10 md:grid-cols-2 bg-gray-100 my-4 px-4 pb-4 rounded-lg"> <form class="bg-white shadow-sm ring-1 ring-gray-900/5 sm:rounded-xl md:col-span-2" autoCapitalize="none" @@ -227,35 +185,65 @@ export function WithdrawalConfirmationQuestion({ e.preventDefault() }} > - <div class="px-4 py-6 sm:p-8"> - <label for="withdraw-amount">{i18n.str`What is`} - <em> - {captchaNumbers.a} + {captchaNumbers.b} - </em> - ? - </label> - - <div class="mt-2"> - <div class="relative rounded-md shadow-sm"> - <input - type="text" - // class="block w-full rounded-md border-0 py-1.5 pl-16 text-gray-900 ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - aria-describedby="answer" - autoFocus - class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" - value={captchaAnswer ?? ""} - required + <div class="px-4 mt-4"> + <div class="w-full"> + <div class="px-4 sm:px-0 text-sm"> + <p><i18n.Translate>Wire transfer details</i18n.Translate></p> + </div> + <div class="mt-6 border-t border-gray-100"> + <dl class="divide-y divide-gray-100"> + {((): VNode => { + switch (details.account.targetType) { + case "iban": { + const p = details.account as PaytoUriIBAN + const name = p.params["receiver-name"] + return <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd> + </div> + {name && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> + </div> + } + </Fragment> + } + case "x-taler-bank": { + const p = details.account as PaytoUriTalerBank + const name = p.params["receiver-name"] + return <Fragment> + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd> + </div> + {name && + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> + </div> + } + </Fragment> + } + default: + return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd> + </div> - name="answer" - id="answer" - autocomplete="off" - onChange={(e): void => { - setCaptchaAnswer(e.currentTarget.value) - }} - /> + } + })()} + <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> + <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <RenderAmount value={details.amount} spec={config.currency_specification} /> + </dd> + </div> + </dl> </div> - <ShowInputErrorLabel message={errors?.answer} isDirty={captchaAnswer !== undefined} /> </div> + </div> <div class="flex items-center justify-between gap-x-6 border-t border-gray-900/10 px-4 py-4 sm:px-8"> @@ -265,7 +253,6 @@ export function WithdrawalConfirmationQuestion({ <i18n.Translate>Cancel</i18n.Translate></button> <button type="submit" class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - disabled={!!errors} onClick={(e) => { e.preventDefault() doTransfer() @@ -279,66 +266,6 @@ export function WithdrawalConfirmationQuestion({ </div> </ShouldBeSameUser> </div> - <div class="px-4 mt-4 "> - <div class="w-full"> - <div class="px-4 sm:px-0 text-sm"> - <p><i18n.Translate>Wire transfer details</i18n.Translate></p> - </div> - <div class="mt-6 border-t border-gray-100"> - <dl class="divide-y divide-gray-100"> - {((): VNode => { - switch (details.account.targetType) { - case "iban": { - const p = details.account as PaytoUriIBAN - const name = p.params["receiver-name"] - return <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.iban}</dd> - </div> - {name && - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> - </div> - } - </Fragment> - } - case "x-taler-bank": { - const p = details.account as PaytoUriTalerBank - const name = p.params["receiver-name"] - return <Fragment> - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.account}</dd> - </div> - {name && - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Exchange name</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{p.params["receiver-name"]}</dd> - </div> - } - </Fragment> - } - default: - return <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Exchange account</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0">{details.account.targetPath}</dd> - </div> - - } - })()} - <div class="px-4 py-2 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-0"> - <dt class="text-sm font-medium leading-6 text-gray-900">Amount</dt> - <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> - <RenderAmount value={details.amount} spec={config.currency_specification} /> - </dd> - </div> - </dl> - </div> - </div> - - </div> </div> </div> diff --git a/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx b/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx index 4bb3b4d7b..7ed5e4b0a 100644 --- a/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalOperationPage.tsx @@ -32,8 +32,10 @@ const logger = new Logger("AccountPage"); export function WithdrawalOperationPage({ operationId, + onAuthorizationRequired, onContinue, }: { + onAuthorizationRequired: () => void; operationId: string; onContinue: () => void; }): VNode { @@ -56,6 +58,7 @@ export function WithdrawalOperationPage({ return ( <WithdrawalQRCode withdrawUri={parsedUri} + onAuthorizationRequired={onAuthorizationRequired} onClose={() => { updateBankState("currentWithdrawalOperationId", undefined) onContinue() diff --git a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx index f05f183d4..97bc9f61f 100644 --- a/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx +++ b/packages/demobank-ui/src/pages/WithdrawalQRCode.tsx @@ -35,6 +35,8 @@ const logger = new Logger("WithdrawalQRCode"); interface Props { withdrawUri: WithdrawUriResult; onClose: () => void; + onAuthorizationRequired: () => void, + } /** * Offer the QR code (and a clickable taler://-link) to @@ -44,6 +46,7 @@ interface Props { export function WithdrawalQRCode({ withdrawUri, onClose, + onAuthorizationRequired, }: Props): VNode { const { i18n } = useTranslationContext(); const result = useWithdrawalDetails(withdrawUri.withdrawalOperationId); @@ -164,6 +167,7 @@ export function WithdrawalQRCode({ reserve: data.selected_reserve_pub, amount: Amounts.parseOrThrow(data.amount) }} + onAuthorizationRequired={onAuthorizationRequired} onAborted={() => { notifyInfo(i18n.str`Operation canceled`); onClose() @@ -173,7 +177,7 @@ export function WithdrawalQRCode({ } -export function OperationNotFound({ onClose }: { onClose: () => void }): VNode { +export function OperationNotFound({ onClose }: { onClose: (() => void) | undefined }): VNode { const { i18n } = useTranslationContext(); return <div class="relative ml-auto mr-auto transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6"> <div> @@ -197,15 +201,17 @@ export function OperationNotFound({ onClose }: { onClose: () => void }): VNode { </div> </div> </div> - <div class="mt-5 sm:mt-6"> - <button type="button" - class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - onClick={async (e) => { - e.preventDefault(); - onClose() - }}> - <i18n.Translate>Cotinue to dashboard</i18n.Translate> - </button> - </div> + {onClose && + <div class="mt-5 sm:mt-6"> + <button type="button" + class="inline-flex w-full justify-center rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + onClick={async (e) => { + e.preventDefault(); + onClose() + }}> + <i18n.Translate>Cotinue to dashboard</i18n.Translate> + </button> + </div> + } </div> }
\ No newline at end of file diff --git a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx index f2972ed65..1676d8b6a 100644 --- a/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx +++ b/packages/demobank-ui/src/pages/account/CashoutListForAccount.tsx @@ -9,10 +9,11 @@ import { CreateCashout } from "../business/CreateCashout.js"; interface Props { account: string, onClose: () => void, + onAuthorizationRequired: () => void, onSelected: (cid: number) => void } -export function CashoutListForAccount({ account, onSelected, onClose }: Props): VNode { +export function CashoutListForAccount({ account, onAuthorizationRequired, onSelected, onClose }: Props): VNode { const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); @@ -29,7 +30,7 @@ export function CashoutListForAccount({ account, onSelected, onClose }: Props): </h1> } - <CreateCashout focus onCancel={onClose} onComplete={() => { }} account={account} /> + <CreateCashout focus onCancel={onClose} onAuthorizationRequired={onAuthorizationRequired} account={account} /> <Cashouts account={account} diff --git a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx index 28875bde6..ca3e2fbdf 100644 --- a/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx +++ b/packages/demobank-ui/src/pages/account/ShowAccountDetails.tsx @@ -1,4 +1,4 @@ -import { HttpStatusCode, TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; +import { AbsoluteTime, HttpStatusCode, TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; import { Loading, LocalNotificationBanner, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -16,9 +16,11 @@ export function ShowAccountDetails({ account, onClear, onUpdateSuccess, + onAuthorizationRequired, }: { onClear?: () => void; onUpdateSuccess: () => void; + onAuthorizationRequired: () => void, account: string; }): VNode { const { i18n } = useTranslationContext(); @@ -54,7 +56,6 @@ export function ShowAccountDetails({ const resp = await api.updateAccount({ token: creds.token, username: account, - }, submitAccount); if (resp.type === "ok") { @@ -99,11 +100,13 @@ export function ShowAccountDetails({ debug: resp.detail, }) case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`Cashout created but confirmation is required.`, - }); + updateBankState("currentChallenge", { + operation: "update-account", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request: submitAccount, + }) + return onAuthorizationRequired() } case TalerErrorCode.BANK_TAN_CHANNEL_NOT_SUPPORTED: { return notify({ @@ -122,7 +125,7 @@ export function ShowAccountDetails({ return ( <Fragment> - <LocalNotificationBanner notification={notification} /> + <LocalNotificationBanner notification={notification} showDebug={true} /> {accountIsTheCurrentUser ? <ProfileNavigation current="details" /> : diff --git a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx index 0ff1cf725..3c4a865ed 100644 --- a/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx +++ b/packages/demobank-ui/src/pages/account/UpdateAccountPassword.tsx @@ -9,17 +9,19 @@ import { doAutoFocus } from "../PaytoWireTransferForm.js"; import { ProfileNavigation } from "../ProfileNavigation.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { LocalNotificationBanner } from "@gnu-taler/web-util/browser"; -import { HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; +import { AbsoluteTime, HttpStatusCode, TalerErrorCode } from "@gnu-taler/taler-util"; import { useBankState } from "../../hooks/bank-state.js"; export function UpdateAccountPassword({ account: accountName, onCancel, onUpdateSuccess, + onAuthorizationRequired, focus, }: { onCancel: () => void; focus?: boolean, + onAuthorizationRequired: () => void, onUpdateSuccess: () => void; account: string; }): VNode { @@ -51,10 +53,11 @@ export function UpdateAccountPassword({ async function doChangePassword() { if (!!errors || !password || !token) return; await handleError(async () => { - const resp = await api.updatePassword({ username: accountName, token }, { + const request = { old_password: current, new_password: password, - }); + } + const resp = await api.updatePassword({ username: accountName, token }, request); if (resp.type === "ok") { notifyInfo(i18n.str`Password changed`); onUpdateSuccess(); @@ -77,11 +80,13 @@ export function UpdateAccountPassword({ title: i18n.str`Your current password doesn't match, can't change to a new password.` }) case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`Cashout created but confirmation is required.`, - }); + updateBankState("currentChallenge", { + operation: "update-password", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request, + }) + return onAuthorizationRequired() } default: assertUnreachable(resp) } diff --git a/packages/demobank-ui/src/pages/admin/AccountForm.tsx b/packages/demobank-ui/src/pages/admin/AccountForm.tsx index 859c04396..7296e7744 100644 --- a/packages/demobank-ui/src/pages/admin/AccountForm.tsx +++ b/packages/demobank-ui/src/pages/admin/AccountForm.tsx @@ -1,9 +1,9 @@ import { AmountString, Amounts, PaytoString, TalerCorebankApi, TranslatedString, buildPayto, parsePaytoUri, stringifyPaytoUri } from "@gnu-taler/taler-util"; -import { CopyButton, ShowInputErrorLabel, useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Attention, CopyButton, ShowInputErrorLabel, useTranslationContext } from "@gnu-taler/web-util/browser"; import { ComponentChildren, Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { useBankCoreApiContext } from "../../context/config.js"; -import { ErrorMessageMappingFor, PartialButDefined, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; +import { ErrorMessageMappingFor, PartialButDefined, TanChannel, WithIntermediate, undefinedIfEmpty, validateIBAN } from "../../utils.js"; import { InputAmount, doAutoFocus } from "../PaytoWireTransferForm.js"; import { assertUnreachable } from "../WithdrawalOperationPage.js"; import { getRandomPassword } from "../rnd.js"; @@ -24,6 +24,7 @@ export type AccountFormData = { cashout_payto_uri?: string, email?: string, phone?: string, + tan_channel?: TanChannel | "remove", } type ChangeByPurposeType = { @@ -55,7 +56,7 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ onChange: ChangeByPurposeType[PurposeType]; purpose: PurposeType; }): VNode { - const { config } = useBankCoreApiContext() + const { config, hints } = useBankCoreApiContext() const { i18n } = useTranslationContext(); const { state: credentials } = useBackendState(); const [form, setForm] = useState<AccountFormData>({}); @@ -75,8 +76,11 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ email: template?.contact_data?.email ?? "", phone: template?.contact_data?.phone ?? "", username: username ?? "", + tan_channel: template?.tan_channel, } + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1 + const showingCurrentUserInfo = credentials.status !== "loggedIn" ? false : username === credentials.username const userIsAdmin = credentials.status !== "loggedIn" ? false : credentials.isUserAdministrator @@ -86,6 +90,9 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ const editableThreshold = userIsAdmin && (purpose === "create" || purpose === "update") const editableAccount = purpose === "create" && userIsAdmin + const hasPhone = !!defaultValue.phone || !!form.phone + const hasEmail = !!defaultValue.email || !!form.email + function updateForm(newForm: typeof defaultValue): void { const cashoutParsed = !newForm.cashout_payto_uri ? undefined @@ -173,6 +180,8 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ payto_uri: internalURI, is_public: !!newForm.isPublic, is_taler_exchange: !!newForm.isExchange, + // @ts-ignore + tan_channel: newForm.tan_channel === "remove" ? null : newForm.tan_channel, } callback(result) return; @@ -190,6 +199,8 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ debit_threshold: threshold, is_public: !!newForm.isPublic, name: newForm.name, + // @ts-ignore + tan_channel: newForm?.tan_channel === "remove" ? null : newForm.tan_channel, } callback(result) return; @@ -409,7 +420,87 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ <span aria-hidden="true" data-enabled={form.isExchange ?? defaultValue.isExchange ? "true" : "false"} class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out"></span> </button> </div> - </div>} + </div> + } + {/* channel, not shown if old cashout api */} + {OLD_CASHOUT_API ? undefined : config.supported_tan_channels.length === 0 ? + <div class="sm:col-span-5"> + <Attention type="warning" title={i18n.str`No cashout channel available`}> + <i18n.Translate> + This server doesn't support second factor authentication. + </i18n.Translate> + </Attention> + </div> + : + <div class="sm:col-span-5"> + <label + class="block text-sm font-medium leading-6 text-gray-900" + for="channel" + > + {i18n.str`Confirmation the operation using`} + </label> + <div class="mt-2 max-w-xl text-sm text-gray-500"> + <div class="px-4 mt-4 grid grid-cols-1 gap-y-6"> + {config.supported_tan_channels.indexOf(TanChannel.EMAIL) === -1 ? undefined : + <label onClick={(e) => { + if (!hasEmail) return; + if (form.tan_channel === TanChannel.EMAIL) { + form.tan_channel = "remove" + } else { + form.tan_channel = TanChannel.EMAIL + } + updateForm(structuredClone(form)) + e.preventDefault() + }} data-disabled={purpose === "show" || !hasEmail} data-selected={(form.tan_channel ?? defaultValue.tan_channel) === TanChannel.EMAIL} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border bg-white data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> + <input type="radio" name="channel" value="Newsletter" class="sr-only" /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span id="project-type-0-label" class="block text-sm font-medium text-gray-900 "> + <i18n.Translate>Email</i18n.Translate> + </span> + {purpose !== "show" && !hasEmail && i18n.str`add a email in your profile to enable this option`} + </span> + </span> + <svg data-selected={(form.tan_channel ?? defaultValue.tan_channel) === TanChannel.EMAIL} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> + </svg> + </label> + } + + {config.supported_tan_channels.indexOf(TanChannel.SMS) === -1 ? undefined : + <label onClick={(e) => { + if (!hasPhone) return; + if (form.tan_channel === TanChannel.SMS) { + form.tan_channel = "remove" + } else { + form.tan_channel = TanChannel.SMS + } + updateForm(structuredClone(form)) + e.preventDefault() + }} data-disabled={purpose === "show" || !hasPhone} data-selected={(form.tan_channel ?? defaultValue.tan_channel) === TanChannel.SMS} + class="relative flex data-[disabled=false]:cursor-pointer rounded-lg border data-[disabled=true]:bg-gray-200 p-4 shadow-sm focus:outline-none border-gray-300 data-[selected=true]:ring-2 data-[selected=true]:ring-indigo-600"> + <input type="radio" name="channel" value="Existing Customers" class="sr-only" /> + <span class="flex flex-1"> + <span class="flex flex-col"> + <span id="project-type-1-label" class="block text-sm font-medium text-gray-900"> + <i18n.Translate>SMS</i18n.Translate> + </span> + {purpose !== "show" && !hasPhone && i18n.str`add a phone number in your profile to enable this option`} + </span> + </span> + <svg data-selected={(form.tan_channel ?? defaultValue.tan_channel) === TanChannel.SMS} class="h-5 w-5 text-indigo-600 data-[selected=false]:hidden" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"> + <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" /> + </svg> + </label> + } + <pre> + {JSON.stringify(form, undefined, 2)} + </pre> + </div> + </div> + </div> + } <div class="sm:col-span-5"> <div class="flex items-center justify-between"> @@ -434,9 +525,6 @@ export function AccountForm<PurposeType extends keyof ChangeByPurposeType>({ </div> </div> - <pre> - {JSON.stringify(errors, undefined, 2)} - </pre> {children} </form> ); diff --git a/packages/demobank-ui/src/pages/admin/AdminHome.tsx b/packages/demobank-ui/src/pages/admin/AdminHome.tsx index 82a341dbe..f5bce1396 100644 --- a/packages/demobank-ui/src/pages/admin/AdminHome.tsx +++ b/packages/demobank-ui/src/pages/admin/AdminHome.tsx @@ -16,18 +16,17 @@ import { AccountList } from "./AccountList.js"; * Query account information and show QR code if there is pending withdrawal */ interface Props { - onRegister: () => void; - onCreateAccount: () => void; onShowAccountDetails: (aid: string) => void; onRemoveAccount: (aid: string) => void; onUpdateAccountPassword: (aid: string) => void; onShowCashoutForAccount: (aid: string) => void; + onAuthorizationRequired: () => void; } -export function AdminHome({ onCreateAccount, onRegister, onRemoveAccount, onShowAccountDetails, onShowCashoutForAccount, onUpdateAccountPassword }: Props): VNode { +export function AdminHome({ onCreateAccount, onAuthorizationRequired, onRemoveAccount, onShowAccountDetails, onShowCashoutForAccount, onUpdateAccountPassword }: Props): VNode { return <Fragment> <Metrics /> - <WireTransfer onRegister={onRegister} /> + <WireTransfer onAuthorizationRequired={onAuthorizationRequired} /> <Transactions account="admin" /> <AccountList @@ -184,11 +183,11 @@ function Metrics(): VNode { </div> </dl> <div class="flex justify-end mt-2"> - <a href="#/download-stats" - class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" - ><i18n.Translate> - download stats as csv - </i18n.Translate></a> + <a href="#/download-stats" + class="disabled:opacity-50 disabled:cursor-default cursor-pointer rounded-md bg-indigo-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" + ><i18n.Translate> + download stats as csv + </i18n.Translate></a> </div> </Fragment> diff --git a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx index 3f7d62935..beadad957 100644 --- a/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx +++ b/packages/demobank-ui/src/pages/admin/RemoveAccount.tsx @@ -1,4 +1,4 @@ -import { Amounts, HttpStatusCode, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; +import { AbsoluteTime, Amounts, HttpStatusCode, TalerError, TalerErrorCode, TranslatedString } from "@gnu-taler/taler-util"; import { Attention, Loading, LocalNotificationBanner, ShowInputErrorLabel, notifyInfo, useLocalNotification, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -16,9 +16,11 @@ export function RemoveAccount({ account, onCancel, onUpdateSuccess, + onAuthorizationRequired, focus, }: { focus?: boolean; + onAuthorizationRequired: () => void, onCancel: () => void; onUpdateSuccess: () => void; account: string; @@ -92,11 +94,13 @@ export function RemoveAccount({ debug: resp.detail, }) case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`The operation needs a confirmation to complete.`, - }); + updateBankState("currentChallenge", { + operation: "delete-account", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request: account, + }) + return onAuthorizationRequired() } default: { assertUnreachable(resp) diff --git a/packages/demobank-ui/src/pages/business/CreateCashout.tsx b/packages/demobank-ui/src/pages/business/CreateCashout.tsx index d97a00a2e..e4fda8fb6 100644 --- a/packages/demobank-ui/src/pages/business/CreateCashout.tsx +++ b/packages/demobank-ui/src/pages/business/CreateCashout.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + AbsoluteTime, Amounts, HttpStatusCode, TalerCorebankApi, @@ -36,7 +37,7 @@ import { import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../../components/ErrorLoadingWithDebug.js"; -import { useBankCoreApiContext } from "../../context/config.js"; +import { VersionHint, useBankCoreApiContext } from "../../context/config.js"; import { useAccountDetails } from "../../hooks/access.js"; import { useBackendState } from "../../hooks/backend.js"; import { @@ -55,7 +56,7 @@ import { useBankState } from "../../hooks/bank-state.js"; interface Props { account: string; focus?: boolean, - onComplete: (id: string) => void; + onAuthorizationRequired: () => void, onCancel?: () => void; } @@ -72,7 +73,7 @@ type ErrorFrom<T> = { export function CreateCashout({ account: accountName, - onComplete, + onAuthorizationRequired, focus, onCancel, }: Props): VNode { @@ -86,7 +87,7 @@ export function CreateCashout({ const creds = credentials.status !== "loggedIn" ? undefined : credentials const [, updateBankState] = useBankState() - const { api, config } = useBankCoreApiContext() + const { api, config, hints } = useBankCoreApiContext() const [form, setForm] = useState<Partial<FormType>>({ isDebit: true, }); const [notification, notify, handleError] = useLocalNotification() const info = useConversionInfo(); @@ -96,6 +97,9 @@ export function CreateCashout({ <i18n.Translate>The bank configuration does not support cashout operations.</i18n.Translate> </Attention> } + + const OLD_CASHOUT_API = hints.indexOf(VersionHint.CASHOUT_BEFORE_2FA) !== -1 + if (!resultAccount) { return <Loading /> } @@ -179,33 +183,37 @@ export function CreateCashout({ : Amounts.isZero(calc.credit) ? i18n.str`the total transfer at destination will be zero` : undefined, - channel: !form.channel ? i18n.str`required` : undefined, + channel: OLD_CASHOUT_API && !form.channel ? i18n.str`required` : undefined, }); const trimmedAmountStr = form.amount?.trim(); async function createCashout() { const request_uid = encodeCrock(getRandomBytes(32)) await handleError(async () => { - const validChannel = config.supported_tan_channels.length === 0 || form.channel + //new cashout api doesn't require channel + const validChannel = !OLD_CASHOUT_API || config.supported_tan_channels.length === 0 || form.channel if (!creds || !form.subject || !validChannel) return; - const resp = await api.createCashout(creds, { + const request = { request_uid, amount_credit: Amounts.stringify(calc.credit), amount_debit: Amounts.stringify(calc.debit), subject: form.subject, tan_channel: form.channel, - }) + } + const resp = await api.createCashout(creds, request) if (resp.type === "ok") { notifyInfo(i18n.str`Cashout created`) } else { switch (resp.case) { case HttpStatusCode.Accepted: { - updateBankState("currentChallengeId", resp.body.challenge_id) - return notify({ - type: "info", - title: i18n.str`Cashout created but confirmation is required.`, - }); + updateBankState("currentChallenge", { + operation: "create-cashout", + id: String(resp.body.challenge_id), + sent: AbsoluteTime.never(), + request, + }) + return onAuthorizationRequired() } case HttpStatusCode.NotFound: return notify({ type: "error", @@ -444,8 +452,8 @@ export function CreateCashout({ </div> )} - {/* channel */} - {config.supported_tan_channels.length === 0 ? + {/* channel, not shown if new cashout api */} + {!OLD_CASHOUT_API ? undefined : config.supported_tan_channels.length === 0 ? <div class="sm:col-span-5"> <Attention type="warning" title={i18n.str`No cashout channel available`}> <i18n.Translate> diff --git a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx index 5d8db5aee..b517a7d42 100644 --- a/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx +++ b/packages/demobank-ui/src/pages/business/ShowCashoutDetails.tsx @@ -93,6 +93,9 @@ export function ShowCashoutDetails({ const errors = undefinedIfEmpty({ code: !code ? i18n.str`required` : undefined, }); + /** + * @deprecated + */ const isPending = String(result.body.status).toUpperCase() === "PENDING"; const { fiat_currency_specification, regional_currency_specification } = info.body // won't implement in retry in old API 3:0:3 since request_uid is missing @@ -266,7 +269,6 @@ export function ShowCashoutDetails({ {!isPending ? undefined : <Fragment> - <div /> <form class="bg-white shadow-sm ring-1 ring-gray-900/5" @@ -318,7 +320,6 @@ export function ShowCashoutDetails({ <i18n.Translate>Confirm</i18n.Translate> </button> </div> - </form> </Fragment>} </div> |