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/pages/SolveChallengePage.tsx | |
parent | ca67640f9f94f1150c0fb67c148dc79daa9d3fa0 (diff) | |
download | wallet-core-82d4ed90caa4a6ea3bdda1fb80ccecf3dc3637f9.tar.xz |
2fa
Diffstat (limited to 'packages/demobank-ui/src/pages/SolveChallengePage.tsx')
-rw-r--r-- | packages/demobank-ui/src/pages/SolveChallengePage.tsx | 553 |
1 files changed, 553 insertions, 0 deletions
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 |