diff options
Diffstat (limited to 'packages/challenger-ui/src/pages/AnswerChallenge.tsx')
-rw-r--r-- | packages/challenger-ui/src/pages/AnswerChallenge.tsx | 296 |
1 files changed, 296 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..69600e2ba --- /dev/null +++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -0,0 +1,296 @@ +/* + 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 { + Button, + LocalNotificationBanner, + 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; + onComplete: () => void; +}; + +function SolveChallengeForm({ + nonce, + onComplete, +}: { + nonce: string; + onComplete: () => void; +}): 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 onSendAgain = + !state || state.email === undefined + ? undefined + : withErrorHandler( + async () => { + if (!state?.email) return; + return await lib.bank.challenge(nonce, { email: state.email }); + }, + (ok) => { + if ('redirectURL' in ok.body) { + completed(ok.body.redirectURL) + } else { + accepted(state.email!, { + 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 = + lastTryError && lastTryError.exhausted + ? undefined + : withErrorHandler( + async () => { + return lib.bank.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> + Please 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 "{state.email}". + </i18n.Translate> + ) : ( + <i18n.Translate> + We recently already sent a TAN to your address " + {state.email}". A new TAN will not be transmitted again + before {state.lastTry.nextSend}. + </i18n.Translate> + )} + </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 + 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"> + <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 again</i18n.Translate> + </Button> + </div> + </form> + </div> + </Fragment> + ); +} + +export function AnswerChallenge({ nonce, onComplete }: Props): VNode { + const { i18n } = useTranslationContext(); + + // const result = useChallengeSession(nonce, clientId, redirectURI, state); + + // if (!result) { + // return <Loading />; + // } + // if (result instanceof TalerError) { + // return <div />; + // } + + // if (result.type === "fail") { + // switch (result.case) { + // case HttpStatusCode.BadRequest: { + // return ( + // <Attention type="danger" title={i18n.str`Bad request`}> + // <i18n.Translate> + // Could not start the challenge, check configuration. + // </i18n.Translate> + // </Attention> + // ); + // } + // case HttpStatusCode.NotFound: { + // return ( + // <Attention type="danger" title={i18n.str`Not found`}> + // <i18n.Translate>Nonce not found</i18n.Translate> + // </Attention> + // ); + // } + // case HttpStatusCode.NotAcceptable: { + // return ( + // <Attention type="danger" title={i18n.str`Not acceptable`}> + // <i18n.Translate> + // Server has wrong template configuration + // </i18n.Translate> + // </Attention> + // ); + // } + // case HttpStatusCode.InternalServerError: { + // return ( + // <Attention type="danger" title={i18n.str`Internal error`}> + // <i18n.Translate>Check logs</i18n.Translate> + // </Attention> + // ); + // } + // default: + // assertUnreachable(result); + // } + // } + + return <SolveChallengeForm nonce={nonce} onComplete={onComplete} />; +} + +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; +} |