diff options
Diffstat (limited to 'packages/challenger-ui/src/pages/AnswerChallenge.tsx')
-rw-r--r-- | packages/challenger-ui/src/pages/AnswerChallenge.tsx | 354 |
1 files changed, 203 insertions, 151 deletions
diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx index 2740e1bdb..48f4db477 100644 --- a/packages/challenger-ui/src/pages/AnswerChallenge.tsx +++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -15,9 +15,9 @@ */ import { AbsoluteTime, - ChallengerApi, + EmptyObject, HttpStatusCode, - TalerProtocolTimestamp, + TalerError, assertUnreachable, } from "@gnu-taler/taler-util"; import { @@ -26,72 +26,107 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + Time, 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}$/; +import { useEffect, useState } from "preact/hooks"; +import { + revalidateChallengeSession, + useChallengeSession, +} from "../hooks/challenge.js"; +import { SessionId, useSessionState } from "../hooks/session.js"; type Props = { - nonce: string; focus?: boolean; + session: SessionId, onComplete: () => void; - routeAsk: RouteDefinition<{ nonce: string }>; + routeAsk: RouteDefinition<EmptyObject>; }; -export function AnswerChallenge({ - focus, - nonce, - onComplete, - routeAsk, -}: Props): VNode { - const { lib } = useChallengerApiContext(); +function useReloadOnDeadline(deadline: AbsoluteTime): void { + const [, set] = useState(false); + function toggle(): void { + set((s) => !s); + } + useEffect(() => { + if (AbsoluteTime.isExpired(deadline)) { + return; + } + const diff = AbsoluteTime.difference(AbsoluteTime.now(), deadline); + if (diff.d_ms === "forever") return; + const timer = setTimeout(toggle, diff.d_ms); + return () => { + clearTimeout(timer); + }; + }, [deadline]); +} + +export function AnswerChallenge({ session, focus, onComplete, routeAsk }: Props): VNode { + const { config, lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); - const { state, accepted, completed } = useSessionState(); + const { sent, failed, completed } = useSessionState(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const [pin, setPin] = useState<string | undefined>(); - const errors = undefinedIfEmpty({ pin: !pin ? i18n.str`Can't be empty` : undefined, }); - const lastEmail = !state + const restrictionKeys = !config.restrictions + ? [] + : Object.keys(config.restrictions); + const restrictionKey = !restrictionKeys.length ? undefined - : !state.lastStatus + : restrictionKeys[0]; + + const result = useChallengeSession(session); + + const lastStatus = + result && !(result instanceof TalerError) && result.type !== "fail" + ? result.body + : undefined; + + const deadline = + lastStatus == undefined ? undefined - : !state.lastStatus.last_address - ? undefined - : state.lastStatus.last_address["email"]; + : AbsoluteTime.fromProtocolTimestamp(lastStatus.retransmission_time); + + useReloadOnDeadline(deadline ?? AbsoluteTime.never()); + + if (!restrictionKey) { + return ( + <div> + invalid server configuration, there is no restriction in /config + </div> + ); + } + + const lastAddr = !lastStatus?.last_address + ? undefined + : lastStatus.last_address[restrictionKey]; + + const unableToChangeAddr = !lastStatus || lastStatus.changes_left < 1; + const contact = lastAddr ? { [restrictionKey]: lastAddr } : undefined; const onSendAgain = - lastEmail === undefined || - state?.lastStatus == undefined || - state?.lastStatus.changes_left === 0 + contact === undefined || + lastStatus == undefined || + lastStatus.pin_transmissions_left === 0 || + !deadline || + !AbsoluteTime.isExpired(deadline) ? undefined : withErrorHandler( async () => { - if (!lastEmail) return; - return await lib.challenger.challenge(nonce, { email: lastEmail }); + return await lib.challenger.challenge(session.nonce, contact); }, (ok) => { if (ok.body.type === "completed") { - completed(new URL(ok.body.redirect_url)); + completed(ok.body); } else { - accepted({ - changeTargetLeft: ok.body.attempts_left, - checkPinLeft: state.lastStatus?.auth_attempts_left ?? 0, - sendCodeLeft: state.lastStatus?.pin_transmissions_left ?? 0, - nextSend: AbsoluteTime.fromProtocolTimestamp( - ok.body.retransmission_time, - ), - transmitted: ok.body.transmitted, - }); + sent(ok.body); } - return undefined; }, (fail) => { switch (fail.case) { @@ -111,27 +146,18 @@ export function AnswerChallenge({ const onCheck = errors !== undefined || - state?.lastStatus == undefined || - state?.lastStatus.auth_attempts_left === 0 + lastStatus == undefined || + lastStatus.auth_attempts_left === 0 ? undefined : withErrorHandler( async () => { - return lib.challenger.solve(nonce, { pin: pin! }); + return lib.challenger.solve(session.nonce, { pin: pin! }); }, (ok) => { if (ok.body.type === "completed") { - completed(new URL(ok.body.redirect_url)); + completed(ok.body); } else { - accepted({ - changeTargetLeft: ok.body.addresses_left, - checkPinLeft: ok.body.auth_attempts_left, - sendCodeLeft: ok.body.pin_transmissions_left, - nextSend: AbsoluteTime.fromProtocolTimestamp( - state?.lastStatus?.retransmission_time ?? - TalerProtocolTimestamp.now(), - ), - transmitted: state?.lastTry?.transmitted ?? false, - }); + failed(ok.body); } onComplete(); }, @@ -140,14 +166,17 @@ export function AnswerChallenge({ case HttpStatusCode.BadRequest: return i18n.str`The request was not accepted, try reloading the app.`; case HttpStatusCode.Forbidden: { + revalidateChallengeSession(); return i18n.str`Invalid pin.`; } case HttpStatusCode.NotFound: return i18n.str`Challenge not found.`; case HttpStatusCode.NotAcceptable: return i18n.str`Server templates are missing due to misconfiguration.`; - case HttpStatusCode.TooManyRequests: + case HttpStatusCode.TooManyRequests: { + revalidateChallengeSession(); return i18n.str`There have been too many attempts to request challenge transmissions.`; + } case HttpStatusCode.InternalServerError: return i18n.str`Server is not able to respond due to internal problems.`; default: @@ -155,13 +184,110 @@ export function AnswerChallenge({ } }, ); + const cantTryAnymore = lastStatus?.auth_attempts_left === 0; + + function LastContactSent(): VNode { + return ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + {!lastStatus || !deadline || AbsoluteTime.isExpired(deadline) ? ( + <i18n.Translate> + Last TAN code was sent to your address "{lastAddr} + " is not valid anymore. + </i18n.Translate> + ) : ( + <Attention + title={i18n.str`A TAN code was sent to your address "${lastAddr}"`} + > + <i18n.Translate> + You should wait until " + <Time format="dd/MM/yyyy HH:mm:ss" timestamp={deadline} /> + " to send a new one. + </i18n.Translate> + </Attention> + )} + </p> + ); + } - if (!state) { - return <div>no state</div>; + function TryAnotherCode(): VNode { + return ( + <div class="mx-auto mt-4 max-w-xl flex justify-between"> + <div> + <a + data-disabled={unableToChangeAddr} + href={unableToChangeAddr ? undefined : routeAsk.url({})} + class="relative data-[disabled=true]:bg-gray-300 data-[disabled=true]:text-white data-[disabled=true]:cursor-default 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>Try with another address</i18n.Translate> + </a> + {lastStatus === undefined ? undefined : ( + <p class="mt-2 text-sm leading-6 text-gray-400"> + {lastStatus.changes_left < 1 ? ( + <i18n.Translate> + You can't change the contact address anymore. + </i18n.Translate> + ) : lastStatus.changes_left === 1 ? ( + <i18n.Translate> + You can change the contact address one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + You can change the contact address {lastStatus.changes_left}{" "} + more times. + </i18n.Translate> + )} + </p> + )} + </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 new code</i18n.Translate> + </Button> + {lastStatus === undefined ? undefined : ( + <p class="mt-2 text-sm leading-6 text-gray-400"> + {lastStatus.pin_transmissions_left < 1 ? ( + <i18n.Translate> + We can't send you the code anymore. + </i18n.Translate> + ) : lastStatus.pin_transmissions_left === 1 ? ( + <i18n.Translate> + We can send the code one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + We can send the code {lastStatus.pin_transmissions_left} more + times. + </i18n.Translate> + )} + </p> + )} + </div> + </div> + ); } - if (!state.lastTry) { - return <div>you should do a challenge first</div>; + if (cantTryAnymore) { + 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>Last TAN code can not be used.</i18n.Translate> + </h2> + + <LastContactSent /> + </div> + + <TryAnotherCode /> + </div> + </Fragment> + ); } return ( @@ -175,33 +301,31 @@ export function AnswerChallenge({ 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"> + <LastContactSent /> + + {lastStatus === undefined ? undefined : ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + {lastStatus.auth_attempts_left < 1 ? ( <i18n.Translate> - We recently already sent a TAN to your address " - {lastEmail}". A new TAN will not be transmitted again - before "{state.lastTry.nextSend}". + You can't check the PIN anymore. </i18n.Translate> - </Attention> - )} - </p> - {!state.lastStatus ? undefined : ( - <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate> - You can try another PIN but just{" "} - {state.lastStatus.auth_attempts_left} times more. - </i18n.Translate> + ) : lastStatus.auth_attempts_left === 1 ? ( + <i18n.Translate> + You can check the PIN one last time. + </i18n.Translate> + ) : ( + <i18n.Translate> + You can check the PIN {lastStatus.auth_attempts_left} more + times. + </i18n.Translate> + )} </p> )} </div> + <form method="POST" - class="mx-auto mt-16 max-w-xl sm:mt-20" + class="mx-auto mt-4 max-w-xl" onSubmit={(e) => { e.preventDefault(); }} @@ -235,25 +359,6 @@ export function AnswerChallenge({ /> </div> </div> - - <p class="mt-3 text-sm leading-6 text-gray-400"> - <i18n.Translate> - We send the code {state.lastTry.checkPinLeft} more times. - </i18n.Translate> - {state.lastTry.checkPinLeft < 1 ? ( - <i18n.Translate> - You can't check the PIN anymore. - </i18n.Translate> - ) : state.lastTry.checkPinLeft === 1 ? ( - <i18n.Translate> - You can check the PIN one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can check the PIN {state.lastTry.checkPinLeft} more times. - </i18n.Translate> - )} - </p> </div> <div class="mt-10"> @@ -266,62 +371,9 @@ export function AnswerChallenge({ <i18n.Translate>Check</i18n.Translate> </Button> </div> - <div class="mt-10 flex justify-between"> - <div> - <a - data-disabled={!state.lastStatus || state.lastStatus.changes_left < 1} - href={routeAsk.url({ nonce })} - class="relative data-[disabled=true]:bg-gray-300 data-[disabled=true]:text-white data-[disabled=true]:cursor-default 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> - {state.lastStatus === undefined ? undefined : - <p class="mt-2 text-sm leading-6 text-gray-400"> - {state.lastStatus.changes_left < 1 ? ( - <i18n.Translate> - You can't change the email anymore. - </i18n.Translate> - ) : state.lastStatus.changes_left === 1 ? ( - <i18n.Translate> - You can change the email one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can change the email {state.lastStatus.changes_left}{" "} - more times. - </i18n.Translate> - )} - </p> - } - </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> - <p class="mt-2 text-sm leading-6 text-gray-400"> - {state.lastTry.sendCodeLeft < 1 ? ( - <i18n.Translate> - We can't send you the code anymore. - </i18n.Translate> - ) : state.lastTry.sendCodeLeft === 1 ? ( - <i18n.Translate> - We can send the code one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - We can send the code {state.lastTry.sendCodeLeft} more - times. - </i18n.Translate> - )} - </p> - </div> - </div> </form> + + <TryAnotherCode /> </div> </Fragment> ); |