diff options
Diffstat (limited to 'packages/challenger-ui/src/pages/AnswerChallenge.tsx')
-rw-r--r-- | packages/challenger-ui/src/pages/AnswerChallenge.tsx | 279 |
1 files changed, 279 insertions, 0 deletions
diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx new file mode 100644 index 000000000..73a79c51f --- /dev/null +++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -0,0 +1,279 @@ +/* + 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 { + ChallengerApi, + HttpStatusCode, + assertUnreachable, +} from "@gnu-taler/taler-util"; +import { + Attention, + Button, + LocalNotificationBanner, + RouteDefinition, + ShowInputErrorLabel, + useChallengerApiContext, + useLocalNotificationHandler, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { Fragment, VNode, h } from "preact"; +import { useState } from "preact/hooks"; +import { useSessionState } from "../hooks/session.js"; + +export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; + +type Props = { + nonce: string; + focus?: boolean; + onComplete: () => void; + routeAsk: RouteDefinition<{ nonce: string }>; +}; + +export function AnswerChallenge({ focus, nonce, onComplete, routeAsk }: Props): VNode { + const { lib } = useChallengerApiContext(); + const { i18n } = useTranslationContext(); + const { state, accepted, completed } = useSessionState(); + const [notification, withErrorHandler] = useLocalNotificationHandler(); + const [pin, setPin] = useState<string | undefined>(); + const [lastTryError, setLastTryError] = + useState<ChallengerApi.InvalidPinResponse>(); + const errors = undefinedIfEmpty({ + pin: !pin ? i18n.str`Can't be empty` : undefined, + }); + + const lastEmail = !state + ? undefined + : !state.lastStatus + ? undefined + : !state.lastStatus.last_address + ? undefined + : state.lastStatus.last_address["email"]; + + const onSendAgain = + !state || lastEmail === undefined + ? undefined + : withErrorHandler( + async () => { + if (!lastEmail) return; + return await lib.challenger.challenge(nonce, { email: lastEmail }); + }, + (ok) => { + if ("redirectURL" in ok.body) { + completed(ok.body.redirectURL); + } else { + accepted({ + attemptsLeft: ok.body.attempts_left, + nextSend: ok.body.next_tx_time, + transmitted: ok.body.transmitted, + }); + } + return undefined; + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str``; + case HttpStatusCode.NotFound: + return i18n.str``; + case HttpStatusCode.NotAcceptable: + return i18n.str``; + case HttpStatusCode.TooManyRequests: + return i18n.str``; + case HttpStatusCode.InternalServerError: + return i18n.str``; + } + }, + ); + + const onCheck = + errors !== undefined || (lastTryError && lastTryError.exhausted) + ? undefined + : withErrorHandler( + async () => { + return lib.challenger.solve(nonce, { pin: pin! }); + }, + (ok) => { + completed(ok.body.redirectURL as URL); + onComplete(); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.BadRequest: + return i18n.str`Invalid request`; + case HttpStatusCode.Forbidden: { + setLastTryError(fail.body); + return i18n.str`Invalid pin`; + } + case HttpStatusCode.NotFound: + return i18n.str``; + case HttpStatusCode.NotAcceptable: + return i18n.str``; + case HttpStatusCode.TooManyRequests: + return i18n.str``; + case HttpStatusCode.InternalServerError: + return i18n.str``; + default: + assertUnreachable(fail); + } + }, + ); + + if (!state) { + return <div>no state</div>; + } + + if (!state.lastTry) { + return <div>you should do a challenge first</div>; + } + + return ( + <Fragment> + <LocalNotificationBanner notification={notification} /> + + <div class="isolate bg-white px-6 py-12"> + <div class="mx-auto max-w-2xl text-center"> + <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> + <i18n.Translate> + Enter the TAN you received to authenticate. + </i18n.Translate> + </h2> + <p class="mt-2 text-lg leading-8 text-gray-600"> + {state.lastTry.transmitted ? ( + <i18n.Translate> + A TAN was sent to your address "{lastEmail}". + </i18n.Translate> + ) : ( + <Attention title={i18n.str`Resend failed`} type="warning"> + <i18n.Translate> + We recently already sent a TAN to your address " + {lastEmail}". A new TAN will not be transmitted again + before "{state.lastTry.nextSend}". + </i18n.Translate> + </Attention> + )} + </p> + {!lastTryError ? undefined : ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + <i18n.Translate> + You can try another PIN but just{" "} + {lastTryError.auth_attempts_left} times more. + </i18n.Translate> + </p> + )} + </div> + <form + method="POST" + class="mx-auto mt-16 max-w-xl sm:mt-20" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="grid grid-cols-1 gap-x-8 gap-y-6"> + <div class="sm:col-span-2"> + <label + for="pin" + class="block text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>TAN code</i18n.Translate> + </label> + <div class="mt-2.5"> + <input + autoFocus + ref={focus ? doAutoFocus : undefined} + type="number" + name="pin" + id="pin" + maxLength={64} + value={pin} + onChange={(e) => { + setPin(e.currentTarget.value); + }} + placeholder="12345678" + class="block w-full rounded-md border-0 px-3.5 py-2 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" + /> + <ShowInputErrorLabel + message={errors?.pin} + isDirty={pin !== undefined} + /> + </div> + </div> + + <p class="mt-3 text-sm leading-6 text-gray-400"> + <i18n.Translate> + You have {state.lastTry.attemptsLeft} attempts left. + </i18n.Translate> + </p> + </div> + + <div class="mt-10"> + <Button + type="submit" + class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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={!onCheck} + handler={onCheck} + > + <i18n.Translate>Check</i18n.Translate> + </Button> + </div> + <div class="mt-10 flex justify-between"> + <div> + <a + href={routeAsk.url({ nonce })} + class="relative disabled:bg-gray-100 disabled:text-gray-500 inline-flex items-center rounded-md bg-white px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus-visible:outline-offset-0" + > + <i18n.Translate>Change email</i18n.Translate> + </a> + </div> + <div> + <Button + type="submit" + disabled={!onSendAgain} + class="block w-full disabled:bg-gray-300 rounded-md bg-indigo-600 px-3.5 py-2.5 text-center 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" + handler={onSendAgain} + > + <i18n.Translate>Send code again</i18n.Translate> + </Button> + </div> + </div> + </form> + </div> + </Fragment> + ); +} + +/** + * Show the element when the load ended + * @param element + */ +export function doAutoFocus(element: HTMLElement | null): void { + if (element) { + setTimeout(() => { + element.focus({ preventScroll: true }); + element.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "center", + }); + }, 100); + } +} + +export function undefinedIfEmpty<T extends object>(obj: T): T | undefined { + return Object.keys(obj).some( + (k) => (obj as Record<string, T>)[k] !== undefined, + ) + ? obj + : undefined; +} |