diff options
Diffstat (limited to 'packages/challenger-ui/src')
-rw-r--r-- | packages/challenger-ui/src/hooks/session.ts | 79 | ||||
-rw-r--r-- | packages/challenger-ui/src/pages/AnswerChallenge.tsx | 44 | ||||
-rw-r--r-- | packages/challenger-ui/src/pages/AskChallenge.tsx | 408 |
3 files changed, 350 insertions, 181 deletions
diff --git a/packages/challenger-ui/src/hooks/session.ts b/packages/challenger-ui/src/hooks/session.ts index f1798885c..2c466147f 100644 --- a/packages/challenger-ui/src/hooks/session.ts +++ b/packages/challenger-ui/src/hooks/session.ts @@ -24,6 +24,7 @@ import { codecForStringURL, codecOptional, ChallengerApi, + codecForList, } from "@gnu-taler/taler-util"; import { buildStorageKey, useLocalStorage } from "@gnu-taler/web-util/browser"; @@ -46,11 +47,22 @@ export type LastChallengeResponse = { transmitted: boolean; }; +interface LastAddress { + address: Record<string, string>; + type: string; + savedAt: AbsoluteTime; +} + export type SessionState = SessionId & { completedURL: string | undefined; - lastAddress: Record<string, string> | undefined; - lastAddressSavedAt: AbsoluteTime | undefined; + lastAddress: Array<LastAddress> | undefined; }; +export const codecForLastAddress = (): Codec<LastAddress> => + buildCodecForObject<LastAddress>() + .property("address", codecForAny()) + .property("type", codecForString()) + .property("savedAt", codecForAbsoluteTime) + .build("LastAddress"); export const codecForSessionState = (): Codec<SessionState> => buildCodecForObject<SessionState>() @@ -59,14 +71,14 @@ export const codecForSessionState = (): Codec<SessionState> => .property("redirectURL", codecForStringURL()) .property("state", codecForString()) .property("completedURL", codecOptional(codecForStringURL())) - .property("lastAddress", codecOptional(codecForAny())) - .property("lastAddressSavedAt", codecOptional(codecForAbsoluteTime)) + .property("lastAddress", codecOptional(codecForList(codecForLastAddress()))) .build("SessionState"); export interface SessionStateHandler { state: SessionState | undefined; start(s: SessionId): void; - saveAddress(address: Record<string, string> | undefined): void; + saveAddress(type: string, address: Record<string, string>): void; + removeAddress(index: number): void; sent(info: ChallengerApi.ChallengeCreateResponse): void; failed(info: ChallengerApi.InvalidPinResponse): void; completed(info: ChallengerApi.ChallengeRedirect): void; @@ -91,27 +103,39 @@ export function useSessionState(): SessionStateHandler { update({ ...info, completedURL: undefined, - lastAddress: state?.lastAddress, - lastAddressSavedAt: state?.lastAddressSavedAt, + lastAddress: state?.lastAddress ?? [], }); - // cleanAllCache(); }, - saveAddress(address) { + removeAddress(index) { if (!state) throw Error("should have an state"); + const lastAddr = [...(state?.lastAddress ?? [])]; + lastAddr.splice(index, 1); update({ ...state, - // completedURL: url.href, - lastAddress: address, - lastAddressSavedAt: AbsoluteTime.now(), + lastAddress: lastAddr, }); }, - sent(info) { + saveAddress(type, address) { if (!state) throw Error("should have an state"); + const lastAddr = [...(state?.lastAddress ?? [])]; + lastAddr.push({ + type, + address: address ?? {}, + savedAt: AbsoluteTime.now(), + }); update({ ...state, + lastAddress: lastAddr, }); }, - failed(info) {}, + sent(info) { + if (!state) throw Error("should have an state"); + // instead of reloading state from server we can update client state + }, + failed(info) { + if (!state) throw Error("should have an state"); + // instead of reloading state from server we can update client state + }, completed(info) { if (!state) throw Error("should have an state"); update({ @@ -119,32 +143,5 @@ export function useSessionState(): SessionStateHandler { 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 }); -// } diff --git a/packages/challenger-ui/src/pages/AnswerChallenge.tsx b/packages/challenger-ui/src/pages/AnswerChallenge.tsx index 265bda038..13ae16a33 100644 --- a/packages/challenger-ui/src/pages/AnswerChallenge.tsx +++ b/packages/challenger-ui/src/pages/AnswerChallenge.tsx @@ -39,8 +39,6 @@ import { } from "../hooks/challenge.js"; import { useSessionState } from "../hooks/session.js"; -export const EMAIL_REGEX = /^[\w-.]+@([\w-]+\.)+[\w-]{2,4}$/; - type Props = { focus?: boolean; onComplete: () => void; @@ -66,7 +64,7 @@ function useReloadOnDeadline(deadline: AbsoluteTime): void { } export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { - const { lib } = useChallengerApiContext(); + const { config, lib } = useChallengerApiContext(); const { i18n } = useTranslationContext(); const { state, sent, failed, completed } = useSessionState(); const [notification, withErrorHandler] = useLocalNotificationHandler(); @@ -75,6 +73,13 @@ export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { pin: !pin ? i18n.str`Can't be empty` : undefined, }); + const restrictionKeys = !config.restrictions + ? [] + : Object.keys(config.restrictions); + const restrictionKey = !restrictionKeys.length + ? undefined + : restrictionKeys[0]; + const result = useChallengeSession(state); const lastStatus = @@ -82,13 +87,6 @@ export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { ? result.body : undefined; - const lastEmail = !lastStatus?.last_address - ? undefined - : lastStatus.last_address["email"]; - - const unableToChangeAddr = !lastStatus || lastStatus.changes_left < 1; - const contact = lastEmail ? { email: lastEmail } : undefined; - const deadline = lastStatus == undefined ? undefined @@ -96,6 +94,21 @@ export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { 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 = !state?.nonce || contact === undefined || @@ -179,12 +192,12 @@ export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { <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 "{contact?.email} + 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 "${contact?.email}"`} + title={i18n.str`A TAN code was sent to your address "${lastAddr}"`} > <i18n.Translate> You should wait until " @@ -212,15 +225,16 @@ export function AnswerChallenge({ focus, onComplete, routeAsk }: Props): VNode { <p class="mt-2 text-sm leading-6 text-gray-400"> {lastStatus.changes_left < 1 ? ( <i18n.Translate> - You can't change the email anymore. + You can't change the contact address anymore. </i18n.Translate> ) : lastStatus.changes_left === 1 ? ( <i18n.Translate> - You can change the email one last time. + You can change the contact address one last time. </i18n.Translate> ) : ( <i18n.Translate> - You can change the email {lastStatus.changes_left} more times. + You can change the contact address {lastStatus.changes_left}{" "} + more times. </i18n.Translate> )} </p> diff --git a/packages/challenger-ui/src/pages/AskChallenge.tsx b/packages/challenger-ui/src/pages/AskChallenge.tsx index 63901f1d9..f0cfb4cc3 100644 --- a/packages/challenger-ui/src/pages/AskChallenge.tsx +++ b/packages/challenger-ui/src/pages/AskChallenge.tsx @@ -18,6 +18,7 @@ import { EmptyObject, HttpStatusCode, TalerError, + TranslatedString, } from "@gnu-taler/taler-util"; import { Attention, @@ -25,6 +26,7 @@ import { LocalNotificationBanner, RouteDefinition, ShowInputErrorLabel, + Time, useChallengerApiContext, useLocalNotificationHandler, useTranslationContext, @@ -51,47 +53,63 @@ export function AskChallenge({ const { state, sent, saveAddress, completed } = useSessionState(); const { lib, config } = useChallengerApiContext(); - const regexEmail = !config.restrictions - ? undefined - : config.restrictions["email"]; - const { i18n } = useTranslationContext(); const [notification, withErrorHandler] = useLocalNotificationHandler(); - const [email, setEmail] = useState<string | undefined>(); + const [address, setEmail] = useState<string | undefined>(); const [repeat, setRepeat] = useState<string | undefined>(); const [remember, setRemember] = useState<boolean>(false); + const [addrIndex, setAddrIndex] = useState<number | undefined>(); + + const restrictionKeys = !config.restrictions + ? [] + : Object.keys(config.restrictions); + const restrictionKey = !restrictionKeys.length + ? undefined + : restrictionKeys[0]; + + const result = useChallengeSession(state); + + if (!restrictionKey) { + return ( + <div> + invalid server configuration, there is no restriction in /config + </div> + ); + } + const regexEmail = !config.restrictions + ? undefined + : config.restrictions[restrictionKey]; const regexTest = regexEmail && regexEmail.regex ? new RegExp(regexEmail.regex) : EMAIL_REGEX; 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 + const prevAddr = !lastStatus?.last_address ? undefined - : lastStatus.last_address["email"]; + : lastStatus.last_address[restrictionKey]; const errors = undefinedIfEmpty({ - email: !email + address: !address ? i18n.str`required` - : !regexTest.test(email) + : !regexTest.test(address) ? regexHint - : prevEmail !== undefined && email === prevEmail - ? i18n.str`email should be different` + : prevAddr !== undefined && address === prevAddr + ? i18n.str`can't use the same address` : undefined, repeat: !repeat ? i18n.str`required` - : email !== repeat - ? i18n.str`emails doesn't match` + : address !== repeat + ? i18n.str`doesn't match` : undefined, }); - const contact = email ? { email } : undefined; + + const contact = address ? { [restrictionKey]: address } : undefined; const onSend = errors || !contact || !state?.nonce @@ -105,7 +123,7 @@ export function AskChallenge({ completed(ok.body); } else { if (remember) { - saveAddress(contact); + saveAddress(config.address_type, contact); } sent(ok.body); } @@ -140,17 +158,33 @@ export function AskChallenge({ <h2 class="text-3xl font-bold tracking-tight text-gray-900 sm:text-4xl"> <i18n.Translate>Enter contact details</i18n.Translate> </h2> - <p class="mt-2 text-lg leading-8 text-gray-600"> - <i18n.Translate> - You will receive an email with a TAN code that must be provided on - the next page. - </i18n.Translate> - </p> + {config.address_type === "email" ? ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + <i18n.Translate> + You will receive an email with a TAN code that must be provided + on the next page. + </i18n.Translate> + </p> + ) : config.address_type === "phone" ? ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + <i18n.Translate> + You will receive an SMS with a TAN code that must be provided on + the next page. + </i18n.Translate> + </p> + ) : ( + <p class="mt-2 text-lg leading-8 text-gray-600"> + <i18n.Translate> + You will receive an message with a TAN code that must be + provided on the next page. + </i18n.Translate> + </p> + )} </div> {lastStatus?.last_address && ( <Fragment> - <Attention title={i18n.str`A code has been sent to ${prevEmail}`}> + <Attention title={i18n.str`A code has been sent to ${prevAddr}`}> <i18n.Translate> <a href={routeSolveChallenge.url({})} class="underline"> <i18n.Translate>Complete the challenge here.</i18n.Translate> @@ -160,90 +194,232 @@ export function AskChallenge({ </Fragment> )} - <form - method="POST" - class="mx-auto mt-4 max-w-xl " - 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>Email</i18n.Translate> - </label> - <div class="mt-2.5"> - <input - type="email" - name="email" - id="email" - ref={focus ? doAutoFocus : undefined} - maxLength={512} - autocomplete="email" - value={email} - onChange={(e) => { - setEmail(e.currentTarget.value); - }} - placeholder={prevEmail} - 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 - message={errors?.email} - isDirty={email !== undefined} - /> - </div> + {!state?.lastAddress || !state.lastAddress.length ? undefined : ( + <div class="mx-auto max-w-xl mt-4"> + <h3> + <i18n.Translate>Previous address</i18n.Translate> + </h3> + <fieldset> + <div class="relative -space-y-px rounded-md bg-white"> + {state.lastAddress.map((addr, idx) => { + return ( + <label + data-checked={addrIndex === idx} + class="relative flex border-gray-200 data-[checked=true]:z-10 data-[checked=true]:bg-indigo-50 cursor-pointer flex-col rounded-tl-md rounded-tr-md border p-4 focus:outline-none md:grid md:grid-cols-2 md:pl-4 md:pr-6" + > + <span class="flex items-center text-sm"> + <input + type="radio" + name={`addr-${idx}`} + value={addr.address[restrictionKey]} + checked={addrIndex === idx} + onClick={() => { + setAddrIndex(idx); + setEmail(addr.address[restrictionKey]); + setRepeat(addr.address[restrictionKey]); + }} + class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600 active:ring-2 active:ring-indigo-600 active:ring-offset-2" + /> + <span + data-checked={addrIndex === idx} + class="ml-3 font-medium text-gray-900 data-[checked=true]:text-indigo-900 " + > + {addr.address[restrictionKey]} + </span> + </span> + <span + data-checked={addrIndex === idx} + class="ml-6 pl-1 text-sm md:ml-0 md:pl-0 md:text-right text-gray-500 data-[checked=true]:text-indigo-700" + > + <i18n.Translate> + Last used at{" "} + <Time + format="dd/MM/yyyy HH:mm:ss" + timestamp={addr.savedAt} + /> + </i18n.Translate> + </span> + </label> + ); + })} + <label + data-checked={addrIndex === undefined} + class="relative rounded-bl-md rounded-br-md flex border-gray-200 data-[checked=true]:z-10 data-[checked=true]:bg-indigo-50 cursor-pointer flex-col rounded-tl-md rounded-tr-md border p-4 focus:outline-none md:grid md:grid-cols-2 md:pl-4 md:pr-6" + > + <span class="flex items-center text-sm"> + <input + type="radio" + name="new-addr" + value="new-addr" + checked={addrIndex === undefined} + onClick={() => { + setAddrIndex(undefined); + setEmail(undefined); + setRepeat(undefined); + }} + class="h-4 w-4 border-gray-300 text-indigo-600 focus:ring-indigo-600 active:ring-2 active:ring-indigo-600 active:ring-offset-2" + /> + <span + data-checked={addrIndex === undefined} + class="ml-3 font-medium text-gray-900 data-[checked=true]:text-indigo-900 " + > + <i18n.Translate>Use new address</i18n.Translate> + </span> + </span> + </label> + </div> + </fieldset> </div> + )} - {lastStatus.fix_address ? undefined : ( + {addrIndex !== undefined ? undefined : ( + <form + method="POST" + class="mx-auto mt-4 max-w-xl " + onSubmit={(e) => { + e.preventDefault(); + }} + > <div class="sm:col-span-2"> <label - for="repeat-email" + for="adress" class="block text-sm font-semibold leading-6 text-gray-900" > - <i18n.Translate>Repeat email</i18n.Translate> + {(function (): TranslatedString { + switch (config.address_type) { + case "email": + return i18n.str`Email`; + case "phone": + return i18n.str`Phone`; + } + })()} </label> <div class="mt-2.5"> <input - type="email" - name="repeat-email" - id="repeat-email" - value={repeat} + type="text" + name="adress" + id="adress" + ref={focus ? doAutoFocus : undefined} + maxLength={512} + autocomplete={(function (): string { + switch (config.address_type) { + case "email": + return "email"; + case "phone": + return "phone"; + } + })()} + value={address} onChange={(e) => { - setRepeat(e.currentTarget.value); + setEmail(e.currentTarget.value); }} - autocomplete="email" - 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" + placeholder={prevAddr} + 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 - message={errors?.repeat} - isDirty={repeat !== undefined} + message={errors?.address} + isDirty={address !== undefined} /> </div> </div> - )} - {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 email anymore. - </i18n.Translate> - ) : lastStatus.changes_left === 1 ? ( - <i18n.Translate> - You can change the email one last time. - </i18n.Translate> - ) : ( - <i18n.Translate> - You can change the email {lastStatus.changes_left} more times. - </i18n.Translate> - )} - </p> - )} + {lastStatus.fix_address ? undefined : ( + <div class="sm:col-span-2"> + <label + for="repeat-address" + class="block text-sm font-semibold leading-6 text-gray-900" + > + {(function (): TranslatedString { + switch (config.address_type) { + case "email": + return i18n.str`Repeat email`; + case "phone": + return i18n.str`Repeat phone`; + } + })()} + </label> + <div class="mt-2.5"> + <input + type="text" + name="repeat-address" + id="repeat-address" + value={repeat} + onChange={(e) => { + setRepeat(e.currentTarget.value); + }} + autocomplete={(function (): string { + switch (config.address_type) { + case "email": + return "email"; + case "phone": + return "phone"; + } + })()} + 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?.repeat} + isDirty={repeat !== undefined} + /> + </div> + </div> + )} + + {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> + )} - {!prevEmail ? ( + <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 class="mx-auto mt-4 max-w-xl "> + {!prevAddr ? ( <div class="mt-10"> <Button type="submit" @@ -251,7 +427,14 @@ export function AskChallenge({ 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={onSend} > - <i18n.Translate>Send email</i18n.Translate> + {(function (): TranslatedString { + switch (config.address_type) { + case "email": + return i18n.str`Send email`; + case "phone": + return i18n.str`Send SMS`; + } + })()} </Button> </div> ) : ( @@ -262,43 +445,18 @@ export function AskChallenge({ 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={onSend} > - <i18n.Translate>Change email</i18n.Translate> + {(function (): TranslatedString { + switch (config.address_type) { + case "email": + return i18n.str`Change email`; + case "phone": + return i18n.str`Change phone`; + } + })()} </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> </div> </Fragment> ); |