/* This file is part of GNU Taler (C) 2022-2024 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 */ import { AbsoluteTime, Amounts, Duration, HttpStatusCode, TalerCorebankApi, TalerError, TalerErrorCode, TranslatedString, assertUnreachable, parsePaytoUri, } from "@gnu-taler/taler-util"; import { Attention, Loading, LocalNotificationBanner, ShowInputErrorLabel, useLocalNotification, useNavigationContext, useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useEffect, useState } from "preact/hooks"; import { ErrorLoadingWithDebug } from "../components/ErrorLoadingWithDebug.js"; import { Time } from "../components/Time.js"; import { useBankCoreApiContext } from "@gnu-taler/web-util/browser"; import { useWithdrawalDetails } from "../hooks/account.js"; import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; import { useConversionInfo } from "../hooks/regional.js"; import { useSessionState } from "../hooks/session.js"; import { RouteDefinition } from "@gnu-taler/web-util/browser"; import { undefinedIfEmpty } from "../utils.js"; import { RenderAmount } from "./PaytoWireTransferForm.js"; import { OperationNotFound } from "./WithdrawalQRCode.js"; import { IdempotencyRetry } from "../../../taler-util/lib/http-client/utils.js"; const TAN_PREFIX = "T-"; const TAN_REGEX = /^([Tt](-)?)?[0-9]*$/; export function SolveChallengePage({ onChallengeCompleted, routeClose, }: { onChallengeCompleted: () => void; routeClose: RouteDefinition; }): VNode { const { lib: { bank: api }, } = useBankCoreApiContext(); const { i18n } = useTranslationContext(); const [bankState, updateBankState] = useBankState(); const [code, setCode] = useState(undefined); const [notification, notify, handleError] = useLocalNotification(); const { state } = useSessionState(); const creds = state.status !== "loggedIn" ? undefined : state; const { navigateTo } = useNavigationContext(); if (!bankState.currentChallenge) { return (
no challenge to solve Continue
); } const ch = bankState.currentChallenge; const errors = undefinedIfEmpty({ code: !code ? i18n.str`Required` : !TAN_REGEX.test(code) ? i18n.str`Confirmation codes are numerical, possibly beginning with 'T-.'` : 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 { const newCh = structuredClone(ch); newCh.sent = AbsoluteTime.now(); newCh.info = undefined; updateBankState("currentChallenge", newCh); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); default: assertUnreachable(resp); } } }); } async function completeChallenge() { if (!creds || !code) return; const tan = code.toUpperCase().startsWith(TAN_PREFIX) ? code.substring(TAN_PREFIX.length) : code; await handleError(async () => { { const resp = await api.confirmChallenge(creds, ch.id, { tan }); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); case HttpStatusCode.TooManyRequests: return notify({ type: "error", title: i18n.str`Too many attempts, try another code.`, description: resp.detail.hint as TranslatedString, debug: resp.detail, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); 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, when: AbsoluteTime.now(), }); 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, undefined, 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, when: AbsoluteTime.now(), }); } // another challenge required, save the request and the ID // @ts-expect-error no need to check the type of request, since it will be the same as the previous request updateBankState("currentChallenge", { operation: ch.operation, id: String(resp.body.challenge_id), location: ch.location, sent: AbsoluteTime.never(), request: ch.request, }); return notify({ type: "info", title: i18n.str`The operation needs another confirmation to complete.`, when: AbsoluteTime.now(), }); } updateBankState("currentChallenge", undefined); return onChallengeCompleted(); } }); } return (

Confirm the operation

This operation is protected with second factor authentication. In order to complete it we need to verify your identity using the authentication channel you provided.

{ updateBankState("currentChallenge", undefined); navigateTo(ch.location); }} /> {ch.info && (
{ e.preventDefault(); }} >
{ e.preventDefault(); const pasted = e.clipboardData?.getData("text/plain"); if (!pasted) return; if (pasted.toUpperCase().startsWith(TAN_PREFIX)) { const sub = pasted.substring(TAN_PREFIX.length); setCode(sub); return; } setCode(pasted); }} name="answer" id="answer" autocomplete="off" onChange={(e): void => { setCode(e.currentTarget.value); }} />

{((ch: TalerCorebankApi.TanChannel): VNode => { switch (ch) { case TalerCorebankApi.TanChannel.SMS: return ( You should have received a code in your phone. ); case TalerCorebankApi.TanChannel.EMAIL: return ( You should have received a code in your email. ); default: assertUnreachable(ch); } })(ch.info.tan_channel)}

The confirmation code starts with "{TAN_PREFIX}" followed by numbers.

)}
); } function ChallengeDetails({ challenge, onStart, onCancel, }: { challenge: ChallengeInProgess; onStart: () => void; onCancel: () => void; }): VNode { const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); const firstTime = AbsoluteTime.isNever(challenge.sent); useEffect(() => { if (firstTime) { onStart(); } }, []); const subtitle = ((op): TranslatedString => { switch (op) { case "delete-account": return i18n.str`Removing account`; case "update-account": return i18n.str`Updating account values`; case "update-password": return i18n.str`Updating password`; case "create-transaction": return i18n.str`Making a wire transfer`; case "confirm-withdrawal": return i18n.str`Confirming withdrawal`; case "create-cashout": return i18n.str`Making a cashout`; } })(challenge.operation); return (

Operation: {" "}   {subtitle}

{((): VNode => { switch (challenge.operation) { case "delete-account": return (
Type
Updating account settings
Account
{challenge.request}
); case "create-transaction": { const payto = parsePaytoUri(challenge.request.payto_uri)!; return ( {challenge.request.amount && (
Amount
)} {payto.isKnown && payto.targetType === "iban" && (
To account
{payto.iban}
)}
); } case "confirm-withdrawal": return ; case "create-cashout": { return ; } case "update-account": { return ( {challenge.request.cashout_payto_uri !== undefined && (
Cashout account
{challenge.request.cashout_payto_uri}
)} {challenge.request.contact_data?.email !== undefined && (
Email
{challenge.request.contact_data?.email}
)} {challenge.request.contact_data?.phone !== undefined && (
Phone
{challenge.request.contact_data?.phone}
)} {challenge.request.debit_threshold !== undefined && (
Debit threshold
)} {challenge.request.is_public !== undefined && (
Is this account public?
{challenge.request.is_public ? i18n.str`Enable` : i18n.str`Disable`}
)} {challenge.request.name !== undefined && (
Name
{challenge.request.name}
)} {challenge.request.tan_channel !== undefined && (
Authentication channel
{challenge.request.tan_channel ?? i18n.str`Remove`}
)}
); } case "update-password": { return (
New password
{challenge.request.new_password}
); } default: assertUnreachable(challenge); } })()}
{challenge.info && (

Challenge details

)}
{challenge.sent.t_ms !== "never" && (
Sent at
)} {challenge.info && (
{((ch: TalerCorebankApi.TanChannel): VNode => { switch (ch) { case TalerCorebankApi.TanChannel.SMS: return To phone; case TalerCorebankApi.TanChannel.EMAIL: return To email; default: assertUnreachable(ch); } })(challenge.info.tan_channel)}
{challenge.info.tan_info}
)}
{challenge.info ? ( ) : (
sending code ...
)}
); } function ShowWithdrawalDetails({ id }: { id: string }): VNode { const details = useWithdrawalDetails(id); const { i18n } = useTranslationContext(); const { config } = useBankCoreApiContext(); if (!details) { return ; } if (details instanceof TalerError) { return ; } if (details.type === "fail") { switch (details.case) { case HttpStatusCode.BadRequest: case HttpStatusCode.NotFound: return ; default: assertUnreachable(details); } } return (
Amount
{details.body.selected_reserve_pub !== undefined && (
Withdraw id
{details.body.selected_reserve_pub.substring(0, 16)}...
)} {details.body.selected_exchange_account !== undefined && (
To account
{details.body.selected_exchange_account}
)}
); } function ShowCashoutDetails({ request, }: { request: TalerCorebankApi.CashoutRequest; }): VNode { const { i18n } = useTranslationContext(); const info = useConversionInfo(); if (!info) { return ; } if (info instanceof TalerError) { return ; } if (info.type === "fail") { switch (info.case) { case HttpStatusCode.NotImplemented: { return ( Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode. ); } default: assertUnreachable(info.case); } } return ( {request.subject !== undefined && (
Subject
{request.subject}
)}
Debit
Credit
); }