diff options
Diffstat (limited to 'packages/bank-ui/src/pages/SolveChallengePage.tsx')
-rw-r--r-- | packages/bank-ui/src/pages/SolveChallengePage.tsx | 716 |
1 files changed, 716 insertions, 0 deletions
diff --git a/packages/bank-ui/src/pages/SolveChallengePage.tsx b/packages/bank-ui/src/pages/SolveChallengePage.tsx new file mode 100644 index 000000000..b2e053b3c --- /dev/null +++ b/packages/bank-ui/src/pages/SolveChallengePage.tsx @@ -0,0 +1,716 @@ +/* + 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 <http://www.gnu.org/licenses/> + */ + +import { + AbsoluteTime, + Amounts, + Duration, + HttpStatusCode, + TalerCorebankApi, + TalerError, + TalerErrorCode, + TranslatedString, + assertUnreachable, + parsePaytoUri, +} from "@gnu-taler/taler-util"; +import { + Attention, + Loading, + LocalNotificationBanner, + ShowInputErrorLabel, + useLocalNotification, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { format } from "date-fns"; +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 { useWithdrawalDetails } from "../hooks/account.js"; +import { useSessionState } from "../hooks/session.js"; +import { ChallengeInProgess, useBankState } from "../hooks/bank-state.js"; +import { useConversionInfo } from "../hooks/regional.js"; +import { RouteDefinition } from "../route.js"; +import { undefinedIfEmpty } from "../utils.js"; +import { RenderAmount } from "./PaytoWireTransferForm.js"; +import { OperationNotFound } from "./WithdrawalQRCode.js"; +import { useNavigationContext } from "../context/navigation.js"; +import { Time } from "../components/Time.js"; + +export function SolveChallengePage({ + onChallengeCompleted, + routeClose, +}: { + onChallengeCompleted: () => void; + routeClose: RouteDefinition; +}): VNode { + const { bank: api } = useBankCoreApiContext(); + const { i18n } = useTranslationContext(); + const [bankState, updateBankState] = useBankState(); + const [code, setCode] = useState<string | undefined>(undefined); + const [notification, notify, handleError] = useLocalNotification(); + const { state } = useSessionState(); + const creds = state.status !== "loggedIn" ? undefined : state; + const { navigateTo } = useNavigationContext(); + + if (!bankState.currentChallenge) { + return ( + <div> + <span>no challenge to solve </span> + <a + href={routeClose.url({})} + name="close" + 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" + > + <i18n.Translate>Continue</i18n.Translate> + </a> + </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 { + 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, + }); + 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 attempts, 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, 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.`, + }); + } + updateBankState("currentChallenge", undefined); + return onChallengeCompleted(); + } + }); + } + + 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} + onCancel={() => { + updateBankState("currentChallenge", undefined); + navigateTo(ch.location) + }} + /> + {ch.info && ( + <div class="mt-2"> + <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-4"> + <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 border-gray-900/10 px-4 py-4 "> + <div /> + <button + type="submit" + name="confirm" + 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(); + e.preventDefault(); + }} + > + <i18n.Translate>Confirm</i18n.Translate> + </button> + </div> + </form> + </div> + )} + </div> + </div> + </Fragment> + ); +} + +function ChallengeDetails({ + challenge, + onStart, + onCancel, +}: { + challenge: ChallengeInProgess; + onStart: () => void; + onCancel: () => void; +}): VNode { + const { i18n, dateLocale } = useTranslationContext(); + const { config } = useBankCoreApiContext(); + + const firstTime = AbsoluteTime.isNever(challenge.sent) + useEffect(() => { + if (firstTime) { + onStart() + } + }, []) + return ( + <div class="px-4 mt-4 "> + <div class="w-full"> + <div class="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"> + <i18n.Translate>Account</i18n.Translate> + </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"> + <i18n.Translate>Amount</i18n.Translate> + </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"> + <i18n.Translate>To account</i18n.Translate> + </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"> + <i18n.Translate>Cashout account</i18n.Translate> + </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"> + <i18n.Translate>Email</i18n.Translate> + </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"> + <i18n.Translate>Phone</i18n.Translate> + </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"> + <i18n.Translate>Debit threshold</i18n.Translate> + </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"> + <i18n.Translate> + Is this account public? + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.is_public + ? i18n.str`Enable` + : i18n.str`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"> + <i18n.Translate>Name</i18n.Translate> + </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"> + <i18n.Translate> + Authentication channel + </i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + {challenge.request.tan_channel ?? i18n.str`Remove`} + </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"> + <i18n.Translate>New password</i18n.Translate> + </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"> + <i18n.Translate>Sent at</i18n.Translate> + </dt> + <dd class="mt-1 text-sm leading-6 text-gray-700 sm:col-span-2 sm:mt-0"> + <Time format="dd/MM/yyyy HH:mm:ss" + timestamp={challenge.sent} + relative={Duration.fromSpec({ days: 1 })} /> + </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 class="mt-6 mb-4 flex justify-between"> + <button + type="button" + name="cancel" + class="text-sm font-semibold leading-6 text-gray-900" + onClick={onCancel} + > + <i18n.Translate>Cancel</i18n.Translate> + </button> + {challenge.info ? ( + <button + type="submit" + name="send again" + 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(); + e.preventDefault(); + }} + > + <i18n.Translate>Send again</i18n.Translate> + </button> + ) : ( + <div> sending code ...</div> + )} + </div> + </div> + </div> + ); +} + +function ShowWithdrawalDetails({ id }: { id: string }): VNode { + const details = useWithdrawalDetails(id); + const { i18n } = useTranslationContext(); + 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 routeClose={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"> + <i18n.Translate>Withdraw id</i18n.Translate> + </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"> + <i18n.Translate>To account</i18n.Translate> + </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} />; + } + if (info.type === "fail") { + switch (info.case) { + case HttpStatusCode.NotImplemented: { + return ( + <Attention + type="danger" + title={i18n.str`Cashout are disabled`} + > + <i18n.Translate>Cashout should be enable by configuration and the conversion rate should be initialized with fee, ratio and rounding mode.</i18n.Translate> + </Attention> + ); + } + default: + assertUnreachable(info.case); + } + } + + 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"> + <i18n.Translate>Subject</i18n.Translate> + </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> + ); +} |