taler-typescript-core

Wallet core logic and WebUIs for various components
Log | Files | Refs | Submodules | README | LICENSE

commit 6a50e3a0b6d56e6365e2247f2ab1d461b2f1fc05
parent e0fa99e21e026e77f3143bf9e62573f9707f2e25
Author: Sebastian <sebasjm@gmail.com>
Date:   Sun, 30 Jun 2024 23:46:23 -0300

removed last status

Diffstat:
Mpackages/challenger-ui/src/Routing.tsx | 151++++++++++++++++++++++++-------------------------------------------------------
Mpackages/challenger-ui/src/app.tsx | 8+++++---
Mpackages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx | 29+++++------------------------
Mpackages/challenger-ui/src/hooks/challenge.ts | 23++++++++++-------------
Mpackages/challenger-ui/src/hooks/session.ts | 98+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/challenger-ui/src/pages/AnswerChallenge.tsx | 175+++++++++++++++++++++++++++++++++++++++++--------------------------------------
Mpackages/challenger-ui/src/pages/AskChallenge.tsx | 141+++++++++++++++++++++++++++++++++++++++----------------------------------------
Mpackages/challenger-ui/src/pages/CallengeCompleted.tsx | 23++++++++++++++---------
Mpackages/challenger-ui/src/pages/Setup.tsx | 161++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
9 files changed, 426 insertions(+), 383 deletions(-)

diff --git a/packages/challenger-ui/src/Routing.tsx b/packages/challenger-ui/src/Routing.tsx @@ -30,7 +30,6 @@ import { AnswerChallenge } from "./pages/AnswerChallenge.js"; import { AskChallenge } from "./pages/AskChallenge.js"; import { CallengeCompleted } from "./pages/CallengeCompleted.js"; import { Frame } from "./pages/Frame.js"; -import { MissingParams } from "./pages/MissingParams.js"; import { NonceNotFound } from "./pages/NonceNotFound.js"; import { Setup } from "./pages/Setup.js"; @@ -45,26 +44,11 @@ export function Routing(): VNode { } const publicPages = { - noinfo: urlPattern<{ nonce: string }>( - /\/noinfo\/(?<nonce>[a-zA-Z0-9]+)/, - ({ nonce }) => `#/noinfo/${nonce}`, - ), - authorize: urlPattern<{ nonce: string }>( - /\/authorize\/(?<nonce>[a-zA-Z0-9]+)/, - ({ nonce }) => `#/authorize/${nonce}`, - ), - ask: urlPattern<{ nonce: string }>( - /\/ask\/(?<nonce>[a-zA-Z0-9]+)/, - ({ nonce }) => `#/ask/${nonce}`, - ), - answer: urlPattern<{ nonce: string }>( - /\/answer\/(?<nonce>[a-zA-Z0-9]+)/, - ({ nonce }) => `#/answer/${nonce}`, - ), - completed: urlPattern<{ nonce: string }>( - /\/completed\/(?<nonce>[a-zA-Z0-9]+)/, - ({ nonce }) => `#/completed/${nonce}`, - ), + noinfo: urlPattern(/\/noinfo/, () => `#/noinfo`), + authorize: urlPattern(/\/authorize/, () => `#/authorize`), + ask: urlPattern(/\/ask/, () => `#/ask`), + answer: urlPattern(/\/answer/, () => `#/answer`), + completed: urlPattern(/\/completed/, () => `#/completed`), setup: urlPattern<{ client: string }>( /\/setup\/(?<client>[0-9]+)/, ({ client }) => `#/setup/${client}`, @@ -79,7 +63,7 @@ function safeGetParam( return ps[n][0]; } -function safeToURL(s: string | undefined): URL | undefined { +export function safeToURL(s: string | undefined): URL | undefined { if (s === undefined) return undefined; try { return new URL(s); @@ -105,58 +89,44 @@ function PublicRounting(): VNode { return <div>no info</div>; } case "setup": { + const secret = safeGetParam(location.params, "secret"); + const redirectURL = safeToURL( + safeGetParam(location.params, "redirect_url"), + ); + return ( <Setup clientId={location.values.client} - onCreated={(nonce) => { - navigateTo(publicPages.ask.url({ nonce })); - //response_type=code - //client_id=1 - //redirect_uri=http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet - //state=123 + secret={secret} + // "http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet" + redirectURL={redirectURL} + onCreated={() => { + navigateTo(publicPages.ask.url({})); }} /> ); } case "authorize": { - const responseType = safeGetParam(location.params, "response_type"); const clientId = safeGetParam(location.params, "client_id"); const redirectURL = safeToURL( - safeGetParam(location.params, "redirect_uri"), + safeGetParam(location.params, "redirect_url"), ); const state = safeGetParam(location.params, "state"); - // http://localhost:8080/app/#/authorize/ASDASD123?response_type=code&client_id=1&redirect_uri=goog.ecom&state=123 - // - // http://localhost:8080/app/?response_type=code&client_id=1&redirect_uri=http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet&state=123#/authorize/X9668AR2CFC26X55H0M87GJZXGM45VD4SZE05C5SNS5FADPWN220 + const sessionId: SessionId | undefined = + !clientId || !redirectURL || !state + ? undefined + : { + clientId, + nonce: location.values.nonce, + redirectURL: redirectURL.href, + state, + }; - if ( - !responseType || - !clientId || - !redirectURL || - !state || - responseType !== "code" - ) { - return <MissingParams />; - } - const sessionId: SessionId = { - clientId, - redirectURL: redirectURL.href, - state, - }; return ( <CheckChallengeIsUpToDate - sessionId={sessionId} - nonce={location.values.nonce} - onNoInfo={() => { - navigateTo( - publicPages.noinfo.url({ - nonce: location.values.nonce, - }), - ); - }} + session={sessionId} onCompleted={() => { - start(sessionId); navigateTo( publicPages.completed.url({ nonce: location.values.nonce, @@ -164,7 +134,6 @@ function PublicRounting(): VNode { ); }} onChangeLeft={() => { - start(sessionId); navigateTo( publicPages.ask.url({ nonce: location.values.nonce, @@ -172,7 +141,6 @@ function PublicRounting(): VNode { ); }} onNoMoreChanges={() => { - start(sessionId); navigateTo( publicPages.ask.url({ nonce: location.values.nonce, @@ -186,26 +154,9 @@ function PublicRounting(): VNode { } case "ask": { return ( - <CheckChallengeIsUpToDate - nonce={location.values.nonce} - onNoInfo={() => { - navigateTo( - publicPages.noinfo.url({ - nonce: location.values.nonce, - }), - ); - }} - onCompleted={() => { - navigateTo( - publicPages.completed.url({ - nonce: location.values.nonce, - }), - ); - }} - > + <CheckChallengeIsUpToDate> <AskChallenge focus - nonce={location.values.nonce} routeSolveChallenge={publicPages.answer} onSendSuccesful={() => { navigateTo( @@ -214,32 +165,22 @@ function PublicRounting(): VNode { }), ); }} + // onCompleted={() => { + // navigateTo( + // publicPages.completed.url({ + // nonce: location.values.nonce, + // }), + // ); + // }} /> </CheckChallengeIsUpToDate> ); } case "answer": { return ( - <CheckChallengeIsUpToDate - nonce={location.values.nonce} - onNoInfo={() => { - navigateTo( - publicPages.noinfo.url({ - nonce: location.values.nonce, - }), - ); - }} - onCompleted={() => { - navigateTo( - publicPages.completed.url({ - nonce: location.values.nonce, - }), - ); - }} - > + <CheckChallengeIsUpToDate> <AnswerChallenge focus - nonce={location.values.nonce} routeAsk={publicPages.ask} onComplete={() => { navigateTo( @@ -248,23 +189,21 @@ function PublicRounting(): VNode { }), ); }} + // onCompleted={() => { + // navigateTo( + // publicPages.completed.url({ + // nonce: location.values.nonce, + // }), + // ); + // }} /> </CheckChallengeIsUpToDate> ); } case "completed": { return ( - <CheckChallengeIsUpToDate - nonce={location.values.nonce} - onNoInfo={() => { - navigateTo( - publicPages.noinfo.url({ - nonce: location.values.nonce, - }), - ); - }} - > - <CallengeCompleted nonce={location.values.nonce} /> + <CheckChallengeIsUpToDate> + <CallengeCompleted /> </CheckChallengeIsUpToDate> ); } diff --git a/packages/challenger-ui/src/app.tsx b/packages/challenger-ui/src/app.tsx @@ -83,9 +83,11 @@ export function App(): VNode { <ChallengerApiProvider baseUrl={new URL("/", baseUrl)} frameOnError={Frame} - evictors={{ - challenger: evictBankSwrCache, - }} + evictors={ + { + // challenger: evictBankSwrCache, + } + } > <SWRConfig value={{ diff --git a/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx b/packages/challenger-ui/src/components/CheckChallengeIsUpToDate.tsx @@ -28,42 +28,23 @@ import { useChallengeSession } from "../hooks/challenge.js"; import { SessionId, useSessionState } from "../hooks/session.js"; interface Props { - nonce: string; + session?: SessionId | undefined; children: ComponentChildren; - sessionId?: SessionId; onCompleted?: () => void; onChangeLeft?: () => void; onNoMoreChanges?: () => void; - onNoInfo: () => void; } export function CheckChallengeIsUpToDate({ - sessionId: sessionFromParam, - nonce, + session, children, onCompleted, onChangeLeft, onNoMoreChanges, - onNoInfo, }: Props): VNode { const { state } = useSessionState(); const { i18n } = useTranslationContext(); - const sessionId = sessionFromParam - ? sessionFromParam - : !state - ? undefined - : { - clientId: state.clientId, - redirectURL: state.redirectURL, - state: state.state, - }; - - const result = useChallengeSession(nonce, sessionId); - - if (!sessionId) { - onNoInfo(); - return <Loading />; - } + const result = useChallengeSession(session ?? state); if (!result) { return <Loading />; @@ -111,7 +92,7 @@ export function CheckChallengeIsUpToDate({ } } - if (onCompleted && "redirectURL" in result.body) { + if (onCompleted && result.body.solved) { onCompleted(); return <Loading />; } @@ -121,7 +102,7 @@ export function CheckChallengeIsUpToDate({ return <Loading />; } - if (onChangeLeft && !result.body.changes_left) { + if (onChangeLeft && result.body.changes_left) { onChangeLeft(); return <Loading />; } diff --git a/packages/challenger-ui/src/hooks/challenge.ts b/packages/challenger-ui/src/hooks/challenge.ts @@ -30,27 +30,24 @@ export function revalidateChallengeSession() { ); } -export function useChallengeSession( - nonce: string, - session: SessionId | undefined, -) { +export function useChallengeSession(session: SessionId | undefined) { const { lib: { challenger: api }, } = useChallengerApiContext(); - async function fetcher([n, c, r, s]: [string, string, string, string]): Promise<any> { - return await api.login(n, c, r, s); + async function fetcher([s]: [SessionId]) { + return await api.login(s.nonce, s.clientId, s.redirectURL, s.state); } const { data, error } = useSWR< ChallengerResultByMethod<"login">, TalerHttpError - >( - !session - ? undefined - : [nonce, session.clientId, session.redirectURL, session.state, "login"], - fetcher, - {}, - ); + >(!session ? undefined : [session, "login"], fetcher, { + revalidateIfStale: false, + errorRetryCount: 0, + errorRetryInterval: 1, + shouldRetryOnError: false, + keepPreviousData: true, + }); if (data) return data; if (error) return error; diff --git a/packages/challenger-ui/src/hooks/session.ts b/packages/challenger-ui/src/hooks/session.ts @@ -16,26 +16,23 @@ import { AbsoluteTime, - ChallengerApi, Codec, buildCodecForObject, codecForAbsoluteTime, codecForAny, - codecForBoolean, - codecForChallengeStatus, - codecForNumber, codecForString, codecForStringURL, codecOptional, + ChallengerApi, } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; -import { mutate } from "swr"; /** * Has the information to reach and * authenticate at the bank's backend. */ export type SessionId = { + nonce: string; clientId: string; redirectURL: string; state: string; @@ -50,29 +47,29 @@ export type LastChallengeResponse = { }; export type SessionState = SessionId & { - lastStatus: ChallengerApi.ChallengeStatus | undefined; completedURL: string | undefined; lastAddress: Record<string, string> | undefined; + lastAddressSavedAt: AbsoluteTime | undefined; }; export const codecForSessionState = (): Codec<SessionState> => buildCodecForObject<SessionState>() + .property("nonce", codecForString()) .property("clientId", codecForString()) .property("redirectURL", codecForStringURL()) .property("state", codecForString()) .property("completedURL", codecOptional(codecForStringURL())) - .property("lastStatus", codecOptional(codecForChallengeStatus())) .property("lastAddress", codecOptional(codecForAny())) + .property("lastAddressSavedAt", codecOptional(codecForAbsoluteTime)) .build("SessionState"); export interface SessionStateHandler { state: SessionState | undefined; start(s: SessionId): void; saveAddress(address: Record<string, string> | undefined): void; - sent(left: number, nextTime: AbsoluteTime): void; - failed(left: number): void; - completed(e: URL): void; - updateStatus(s: ChallengerApi.ChallengeStatus): void; + sent(info: ChallengerApi.ChallengeCreateResponse): void; + failed(info: ChallengerApi.InvalidPinResponse): void; + completed(info: ChallengerApi.ChallengeRedirect): void; } const SESSION_STATE_KEY = buildStorageKey( @@ -94,47 +91,60 @@ export function useSessionState(): SessionStateHandler { update({ ...info, completedURL: undefined, - lastStatus: undefined, lastAddress: state?.lastAddress, + lastAddressSavedAt: state?.lastAddressSavedAt, + }); + // cleanAllCache(); + }, + saveAddress(address) { + if (!state) throw Error("should have an state"); + update({ + ...state, + // completedURL: url.href, + lastAddress: address, + lastAddressSavedAt: AbsoluteTime.now(), }); - cleanAllCache(); }, - saveAddress(address) {}, - sent(left: number, nextTime: AbsoluteTime) {}, - failed(left: number) {}, - completed(url) { - if (!state) return; + sent(info) { + if (!state) throw Error("should have an state"); update({ ...state, - completedURL: url.href, }); }, - updateStatus(st: ChallengerApi.ChallengeStatus) { - if (!state) return; - if (!state.lastStatus) { - update({ - ...state, - lastStatus: st, - }); - return; - } - // current status, FIXME: better check to know if the state changed - const ls = state.lastStatus; - if ( - ls.changes_left !== st.changes_left || - ls.fix_address !== st.fix_address || - ls.last_address !== st.last_address - ) { - update({ - ...state, - lastStatus: st, - }); - return; - } + failed(info) {}, + completed(info) { + if (!state) throw Error("should have an state"); + update({ + ...state, + completedURL: info.redirect_url, + }); }, + // updateStatus(st: ChallengerApi.ChallengeStatus) { + // if (!state) return; + // if (!state.lastStatus) { + // update({ + // ...state, + // lastStatus: st, + // }); + // return; + // } + // // current status, FIXME: better check to know if the state changed + // const ls = state.lastStatus; + // if ( + // ls.changes_left !== st.changes_left || + // ls.fix_address !== st.fix_address || + // ls.last_address !== st.last_address + // ) { + // update({ + // ...state, + // lastStatus: st, + // }); + // return; + // } + // }, }; } -function cleanAllCache(): void { - mutate(() => true, undefined, { revalidate: false }); -} +// function cleanAllCache(): void { +// mutate(() => true, undefined, { revalidate: false }); +// } diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -16,7 +16,9 @@ import { AbsoluteTime, ChallengerApi, + EmptyObject, HttpStatusCode, + TalerError, TalerProtocolTimestamp, assertUnreachable, } from "@gnu-taler/taler-util"; @@ -32,61 +34,83 @@ import { useTranslationContext, } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; -import { useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { useSessionState } from "../hooks/session.js"; +import { useChallengeSession } from "../hooks/challenge.js"; export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; type Props = { - nonce: string; focus?: boolean; onComplete: () => void; - routeAsk: RouteDefinition<{ nonce: string }>; + routeAsk: RouteDefinition<EmptyObject>; }; -export function AnswerChallenge({ - focus, - nonce, - onComplete, - routeAsk, -}: Props): VNode { +function useReloadOnDeadline(deadline: AbsoluteTime): void { + const [, set] = useState(false); + useEffect(() => { + if (AbsoluteTime.isExpired(deadline)) { + return; + } + const diff = AbsoluteTime.difference(AbsoluteTime.now(), deadline); + if (diff.d_ms === "forever") return; + const p = setTimeout(() => { + set(true); + }, diff.d_ms); + return () => { + clearTimeout(p); + }; + }, []); +} + +export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { const { lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const { state, 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 result = useChallengeSession(state); + + const lastStatus = + result && !(result instanceof TalerError) && result.type !== "fail" + ? result.body + : undefined; + + const lastEmail = !lastStatus?.last_address ? undefined - : !state.lastStatus - ? undefined - : !state.lastStatus.last_address - ? undefined - : state.lastStatus.last_address["email"]; + : lastStatus.last_address["email"]; + const unableToChangeAddr = !lastStatus || lastStatus.changes_left < 1; const contact = lastEmail ? { email: lastEmail } : undefined; + const deadline = + lastStatus == undefined + ? undefined + : AbsoluteTime.fromProtocolTimestamp(lastStatus.retransmission_time); + + useReloadOnDeadline(deadline ?? AbsoluteTime.never()); + const onSendAgain = + !state?.nonce || contact === undefined || - state?.lastStatus == undefined || - state?.lastStatus.changes_left === 0 + lastStatus == undefined || + lastStatus.auth_attempts_left === 0 || + !deadline || + !AbsoluteTime.isExpired(deadline) ? undefined : withErrorHandler( async () => { - return await lib.challenger.challenge(nonce, contact); + return await lib.challenger.challenge(state.nonce, contact); }, (ok) => { if (ok.body.type === "completed") { - completed(new URL(ok.body.redirect_url)); + completed(ok.body); } else { - sent( - ok.body.attempts_left, - AbsoluteTime.fromProtocolTimestamp(ok.body.retransmission_time), - ); + sent(ok.body); } return undefined; }, @@ -107,19 +131,20 @@ export function AnswerChallenge({ ); const onCheck = + !state?.nonce || 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(state.nonce, { pin: pin! }); }, (ok) => { if (ok.body.type === "completed") { - completed(new URL(ok.body.redirect_url)); + completed(ok.body); } else { - failed(ok.body.pin_transmissions_left); + failed(ok.body); } onComplete(); }, @@ -144,10 +169,6 @@ export function AnswerChallenge({ }, ); - if (!state) { - return <div>no state</div>; - } - return ( <Fragment> <LocalNotificationBanner notification={notification} /> @@ -160,43 +181,50 @@ export function AnswerChallenge({ </i18n.Translate> </h2> <p class="mt-2 text-lg leading-8 text-gray-600"> - {state.lastStatus?.last_address ? ( + {!lastStatus || !deadline || AbsoluteTime.isExpired(deadline) ? ( <i18n.Translate> - A TAN was sent to your address &quot;{lastEmail}&quot;. + Last TAN code was sent to your address &quot;{contact?.email} + &quot;. </i18n.Translate> ) : ( - <Attention title={i18n.str`Resend failed`} type="warning"> + <Attention title={i18n.str`Unable send the code again`}> <i18n.Translate> We recently already sent a TAN to your address &quot; - {lastEmail}&quot;. A new TAN will not be transmitted again - before &quot; + {contact?.email}&quot;. A new TAN will not be transmitted + again before &quot; <Time format="dd/MM/yyyy HH:mm:ss" - timestamp={ - state.lastStatus?.retransmission_time === undefined - ? undefined - : AbsoluteTime.fromProtocolTimestamp( - state.lastStatus?.retransmission_time, - ) - } + timestamp={AbsoluteTime.fromProtocolTimestamp( + lastStatus.retransmission_time, + )} /> &quot;. </i18n.Translate> </Attention> )} </p> - {!state.lastStatus ? undefined : ( + {lastStatus === undefined ? 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&#39;t check the PIN anymore. + </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-4 max-w-xl sm:mt-20" + class="mx-auto mt-4 max-w-xl" onSubmit={(e) => { e.preventDefault(); }} @@ -230,25 +258,6 @@ export function AnswerChallenge({ /> </div> </div> - - {state.lastStatus === undefined ? undefined : ( - <p class="mt-3 text-sm leading-6 text-gray-400"> - {state.lastStatus.auth_attempts_left < 1 ? ( - <i18n.Translate> - You can&#39;t check the PIN anymore. - </i18n.Translate> - ) : state.lastStatus.auth_attempts_left === 1 ? ( - <i18n.Translate> - You can check the PIN one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can check the PIN {state.lastStatus.auth_attempts_left}{" "} - more times. - </i18n.Translate> - )} - </p> - )} </div> <div class="mt-10"> @@ -264,28 +273,26 @@ export function AnswerChallenge({ <div class="mt-10 flex justify-between"> <div> <a - data-disabled={ - !state.lastStatus || state.lastStatus.changes_left < 1 - } - href={routeAsk.url({ nonce })} + 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>Change email</i18n.Translate> </a> - {state.lastStatus === undefined ? undefined : ( + {lastStatus === undefined ? undefined : ( <p class="mt-2 text-sm leading-6 text-gray-400"> - {state.lastStatus.changes_left < 1 ? ( + {lastStatus.changes_left < 1 ? ( <i18n.Translate> You can&#39;t change the email anymore. </i18n.Translate> - ) : state.lastStatus.changes_left === 1 ? ( + ) : 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. + You can change the email {lastStatus.changes_left} more + times. </i18n.Translate> )} </p> @@ -300,20 +307,20 @@ export function AnswerChallenge({ > <i18n.Translate>Send code again</i18n.Translate> </Button> - {state.lastStatus === undefined ? undefined : ( + {lastStatus === undefined ? undefined : ( <p class="mt-2 text-sm leading-6 text-gray-400"> - {state.lastStatus.pin_transmissions_left < 1 ? ( + {lastStatus.pin_transmissions_left < 1 ? ( <i18n.Translate> We can&#39;t send you the code anymore. </i18n.Translate> - ) : state.lastStatus.pin_transmissions_left === 1 ? ( + ) : lastStatus.pin_transmissions_left === 1 ? ( <i18n.Translate> We can send the code one last time. </i18n.Translate> ) : ( <i18n.Translate> - We can send the code{" "} - {state.lastStatus.pin_transmissions_left} more times. + We can send the code {lastStatus.pin_transmissions_left}{" "} + more times. </i18n.Translate> )} </p> diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx @@ -13,7 +13,12 @@ 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, HttpStatusCode } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + EmptyObject, + HttpStatusCode, + TalerError, +} from "@gnu-taler/taler-util"; import { Attention, Button, @@ -28,21 +33,17 @@ import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; import { useSessionState } from "../hooks/session.js"; import { doAutoFocus } from "./AnswerChallenge.js"; +import { useChallengeSession } from "../hooks/challenge.js"; -type Form = { - email: string; -}; export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; type Props = { - nonce: string; onSendSuccesful: () => void; - routeSolveChallenge: RouteDefinition<{ nonce: string }>; + routeSolveChallenge: RouteDefinition<EmptyObject>; focus?: boolean; }; export function AskChallenge({ - nonce, onSendSuccesful, routeSolveChallenge, focus, @@ -50,9 +51,6 @@ export function AskChallenge({ const { state, sent, saveAddress, completed } = useSessionState(); const { lib, config } = useChallengerApiContext(); - const status = state?.lastStatus; - const prevEmail = - !status || !status.last_address ? undefined : status.last_address["email"]; const regexEmail = !config.restrictions ? undefined : config.restrictions["email"]; @@ -68,6 +66,17 @@ export function AskChallenge({ const regexHint = regexEmail && regexEmail.hint ? regexEmail.hint : i18n.str`invalid email`; + const result = useChallengeSession(state); + + const lastStatus = + result && !(result instanceof TalerError) && result.type !== "fail" + ? result.body + : undefined; + + const prevEmail = !lastStatus?.last_address + ? undefined + : lastStatus.last_address["email"]; + const errors = undefinedIfEmpty({ email: !email ? i18n.str`required` @@ -85,21 +94,20 @@ export function AskChallenge({ const contact = email ? { email } : undefined; const onSend = - errors || !contact + errors || !contact || !state?.nonce ? undefined : withErrorHandler( async () => { - return lib.challenger.challenge(nonce, contact); + return lib.challenger.challenge(state.nonce, contact); }, (ok) => { if (ok.body.type === "completed") { - completed(new URL(ok.body.redirect_url)); + completed(ok.body); } else { - saveAddress(contact); - sent( - ok.body.attempts_left, - AbsoluteTime.fromProtocolTimestamp(ok.body.retransmission_time), - ); + if (remember) { + saveAddress(contact); + } + sent(ok.body); } onSendSuccesful(); }, @@ -119,7 +127,7 @@ export function AskChallenge({ }, ); - if (!status) { + if (!lastStatus) { return <div>no status loaded</div>; } @@ -139,34 +147,26 @@ export function AskChallenge({ </i18n.Translate> </p> </div> - {state.lastStatus?.last_address && ( + + {lastStatus?.last_address && ( <Fragment> <Attention title={i18n.str`A code has been sent to ${prevEmail}`}> <i18n.Translate> - <a href={routeSolveChallenge.url({ nonce })} class="underline"> + <a href={routeSolveChallenge.url({})} class="underline"> <i18n.Translate>Complete the challenge here.</i18n.Translate> </a> </i18n.Translate> </Attention> </Fragment> )} + <form method="POST" - class="mx-auto mt-4 max-w-xl sm:mt-20" + class="mx-auto mt-4 max-w-xl " onSubmit={(e) => { e.preventDefault(); }} > - <div class="py-4"> - <Attention title={i18n.str`A code has been sent to ${prevEmail}`}> - <i18n.Translate> - <a href={routeSolveChallenge.url({ nonce })} class="underline"> - <i18n.Translate>Complete the challenge here.</i18n.Translate> - </a> - </i18n.Translate> - </Attention> - </div> - <div class="sm:col-span-2"> <label for="email" @@ -187,7 +187,7 @@ export function AskChallenge({ setEmail(e.currentTarget.value); }} placeholder={prevEmail} - readOnly={status.fix_address} + readOnly={lastStatus.fix_address} class="block w-full read-only:bg-slate-200 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 @@ -197,39 +197,7 @@ export function AskChallenge({ </div> </div> - <div class="flex items-center justify-between py-2"> - <span class="flex flex-grow flex-col"> - <span - class="text-sm text-black font-medium leading-6 " - id="availability-label" - > - <i18n.Translate> - Remember this address for future challenges. - </i18n.Translate> - </span> - </span> - <button - type="button" - name={`remember switch`} - data-enabled={remember} - class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" - role="switch" - aria-checked="false" - aria-labelledby="availability-label" - aria-describedby="availability-description" - onClick={() => { - setRemember(!remember); - }} - > - <span - aria-hidden="true" - data-enabled={remember} - class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" - ></span> - </button> - </div> - - {status.fix_address ? undefined : ( + {lastStatus.fix_address ? undefined : ( <div class="sm:col-span-2"> <label for="repeat-email" @@ -257,20 +225,19 @@ export function AskChallenge({ </div> )} - {state.lastStatus === undefined ? undefined : ( + {lastStatus === undefined ? undefined : ( <p class="mt-2 text-sm leading-6 text-gray-400"> - {state.lastStatus.changes_left < 1 ? ( + {lastStatus.changes_left < 1 ? ( <i18n.Translate> You can&#39;t change the email anymore. </i18n.Translate> - ) : state.lastStatus.changes_left === 1 ? ( + ) : 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. + You can change the email {lastStatus.changes_left} more times. </i18n.Translate> )} </p> @@ -299,6 +266,38 @@ export function AskChallenge({ </Button> </div> )} + + <div class="flex items-center justify-between py-2"> + <span class="flex flex-grow flex-col"> + <span + class="text-sm text-black font-medium leading-6 " + id="availability-label" + > + <i18n.Translate> + Remember this address for future challenges. + </i18n.Translate> + </span> + </span> + <button + type="button" + name={`remember switch`} + data-enabled={remember} + class="bg-indigo-600 data-[enabled=false]:bg-gray-200 relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-indigo-600 focus:ring-offset-2" + role="switch" + aria-checked="false" + aria-labelledby="availability-label" + aria-describedby="availability-description" + onClick={() => { + setRemember(!remember); + }} + > + <span + aria-hidden="true" + data-enabled={remember} + class="translate-x-5 data-[enabled=false]:translate-x-0 pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out" + ></span> + </button> + </div> </form> </div> </Fragment> diff --git a/packages/challenger-ui/src/pages/CallengeCompleted.tsx b/packages/challenger-ui/src/pages/CallengeCompleted.tsx @@ -14,13 +14,19 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { VNode, h } from "preact"; +import { useChallengeSession } from "../hooks/challenge.js"; +import { useSessionState } from "../hooks/session.js"; +import { TalerError } from "@gnu-taler/taler-util"; -type Props = { - nonce: string; +type Props = {}; +export function CallengeCompleted({}: Props): VNode { + const { state } = useSessionState(); + const result = useChallengeSession(state); + + const lastStatus = + result && !(result instanceof TalerError) && result.type !== "fail" + ? result.body + : undefined; + + return <div>completed {lastStatus}</div>; } -export function CallengeCompleted({nonce}:Props):VNode { - - return <div> - completed {nonce} - </div> -} -\ No newline at end of file diff --git a/packages/challenger-ui/src/pages/Setup.tsx b/packages/challenger-ui/src/pages/Setup.tsx @@ -13,47 +13,84 @@ 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 { AccessToken, HttpStatusCode, encodeCrock, randomBytes } from "@gnu-taler/taler-util"; +import { + HttpStatusCode, + createClientSecretAccessToken, + createRFC8959AccessTokenEncoded, + encodeCrock, + randomBytes, +} 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 { safeToURL } from "../Routing.js"; import { useSessionState } from "../hooks/session.js"; +import { doAutoFocus, undefinedIfEmpty } from "./AnswerChallenge.js"; type Props = { clientId: string; - onCreated: (nonce:string) => void; + secret: string | undefined; + redirectURL: URL | undefined; + onCreated: () => void; + focus?: boolean; }; -export function Setup({ clientId, onCreated }: Props): VNode { +export function Setup({ + clientId, + secret, + redirectURL, + focus, + onCreated, +}: Props): VNode { const { i18n } = useTranslationContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); const { lib } = useChallengerApiContext(); const { start } = useSessionState(); + const [password, setPassword] = useState<string | undefined>(secret); + const [url, setUrl] = useState<string | undefined>(redirectURL?.href); - const onStart = withErrorHandler( - async () => { - return lib.challenger.setup(clientId, "secret-token:chal-secret" as AccessToken); - }, - (ok) => { - start({ - clientId, - redirectURL: "http://exchange.taler.test:1180/kyc-proof/kyc-provider-wallet", - state: encodeCrock(randomBytes(32)), - }); + const errors = undefinedIfEmpty({ + password: !password ? i18n.str`required` : undefined, + url: !url + ? i18n.str`required` + : !safeToURL(url) + ? i18n.str`invalid format` + : undefined, + }); - onCreated(ok.body.nonce); - }, - (fail) => { - switch (fail.case) { - case HttpStatusCode.NotFound: - return i18n.str`Client doesn't exist.`; - } - }, - ); + const onStart = + !!errors || password === undefined || url === undefined + ? undefined + : withErrorHandler( + async () => { + return lib.challenger.setup( + clientId, + createRFC8959AccessTokenEncoded(password), + ); + }, + (ok) => { + start({ + nonce: ok.body.nonce, + clientId, + redirectURL: url, + state: encodeCrock(randomBytes(32)), + }); + + onCreated(); + }, + (fail) => { + switch (fail.case) { + case HttpStatusCode.NotFound: + return i18n.str`Client doesn't exist.`; + } + }, + ); return ( <Fragment> @@ -67,15 +104,81 @@ export function Setup({ clientId, onCreated }: Props): VNode { </i18n.Translate> </h2> </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" - handler={onStart} + + <form + method="POST" + class="mx-auto mt-4 max-w-xl sm:mt-20" + onSubmit={(e) => { + e.preventDefault(); + }} + > + <div class="sm:col-span-2"> + <label + for="email" + class="block text-sm font-semibold leading-6 text-gray-900" > - <i18n.Translate>Start</i18n.Translate> - </Button> + <i18n.Translate>Password</i18n.Translate> + </label> + <div class="mt-2.5"> + <input + type="password" + name="password" + id="password" + ref={focus ? doAutoFocus : undefined} + maxLength={512} + autocomplete="password" + value={password} + onChange={(e) => { + setPassword(e.currentTarget.value); + }} + readOnly={secret !== undefined} + class="block w-full read-only:bg-slate-200 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?.password} + isDirty={password !== undefined} + /> + </div> </div> + + <div class="sm:col-span-2"> + <label + for="email" + class="block text-sm font-semibold leading-6 text-gray-900" + > + <i18n.Translate>Redirect URL</i18n.Translate> + </label> + <div class="mt-2.5"> + <input + type="text" + name="redirect_url" + id="redirect_url" + maxLength={512} + autocomplete="redirect_url" + value={url} + onChange={(e) => { + setUrl(e.currentTarget.value); + }} + readOnly={redirectURL !== undefined} + class="block w-full read-only:bg-slate-200 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?.url} + isDirty={url !== undefined} + /> + </div> + </div> + </form> + <div class="mt-10"> + <Button + type="submit" + disabled={!onStart} + 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={onStart} + > + <i18n.Translate>Start</i18n.Translate> + </Button> + </div> </div> </Fragment> );