taler-typescript-core

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

commit 5ef82cb7e4b9b645027ff7e74c39f7af0826d90f
parent 9bc232be913350e18766e7b314927ea0e52c7880
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 15 Sep 2025 09:21:59 -0300

fix #10374

Diffstat:
Mpackages/merchant-backoffice-ui/src/Application.tsx | 3+++
Mpackages/merchant-backoffice-ui/src/Routing.tsx | 2+-
Apackages/merchant-backoffice-ui/src/components/SolveMFA.tsx | 391+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/components/notifications/index.tsx | 6++++--
Mpackages/merchant-backoffice-ui/src/paths/admin/create/index.tsx | 107+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/merchant-backoffice-ui/src/paths/admin/list/index.tsx | 172++++++++++++++++++++++++++++++++++++++-----------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx | 94++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx | 88+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx | 165+++++++++++++++++++++++++++++++++++++++++++++----------------------------------
Mpackages/merchant-backoffice-ui/src/paths/instance/password/DetailPage.tsx | 34++++++++++++++++++++--------------
Mpackages/merchant-backoffice-ui/src/paths/instance/password/index.tsx | 160++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mpackages/merchant-backoffice-ui/src/paths/login/index.tsx | 52+++++++++++++++++++++++++++++++++++++++-------------
Mpackages/merchant-backoffice-ui/src/paths/newAccount/index.tsx | 84++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mpackages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx | 169++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------
Mpackages/taler-util/src/http-client/merchant.ts | 26++++++++++++++++++--------
15 files changed, 1087 insertions(+), 466 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/Application.tsx b/packages/merchant-backoffice-ui/src/Application.tsx @@ -401,6 +401,9 @@ const swrCacheEvictor = new (class case TalerMerchantInstanceCacheEviction.LAST: { return; } + default: { + assertUnreachable(op) + } } } })(); diff --git a/packages/merchant-backoffice-ui/src/Routing.tsx b/packages/merchant-backoffice-ui/src/Routing.tsx @@ -137,7 +137,7 @@ export enum InstancePaths { interface = "/interface", newAccount = "/account/new", - resetAccount = "/account/reset", + resetAccount = "/account/reset/:id", } export enum AdminPaths { diff --git a/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx b/packages/merchant-backoffice-ui/src/components/SolveMFA.tsx @@ -0,0 +1,391 @@ +import { + undefinedIfEmpty, + useTranslationContext, +} from "@gnu-taler/web-util/browser"; +import { h, VNode, Fragment } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "./exception/AsyncButton.js"; +import { NotificationCard } from "./menu/index.js"; +import { useSessionContext } from "../context/session.js"; +import { Notification } from "../utils/types.js"; +import { + assertUnreachable, + Challenge, + ChallengeResponse, + HttpStatusCode, + TalerErrorCode, + TanChannel, +} from "@gnu-taler/taler-util"; +import { FormErrors, FormProvider } from "./form/FormProvider.js"; +import { Input } from "./form/Input.js"; + +export interface Props { + onCompleted(challenges: string[]): void; + onCancel(): void; + currentChallenge: ChallengeResponse; +} + +interface Form { + code: string; +} + +function SolveChallenge({ + challenge, + onCancel, + onSolved, +}: { + onCancel: () => void; + challenge: Challenge; + onSolved: () => void; +}): VNode { + const { i18n } = useTranslationContext(); + const { state: session, lib, logIn } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const [value, setValue] = useState<Partial<Form>>({}); + const errors = undefinedIfEmpty<FormErrors<Form>>({ + code: !value.code ? i18n.str`Required` : undefined, + }); + function valueHandler(s: (d: Partial<Form>) => Partial<Form>): void { + const next = s(value); + const v: Form = { + code: next.code ?? "", + }; + setValue(v); + } + + async function doVerificationImpl() { + try { + const resp = await lib.instance.confirmChallenge(challenge.challenge_id, { + tan: value.code!, + }); + if (resp.case === "ok") { + return onSolved(); + } + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Failed to validate the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_FAILED: { + setNotif({ + message: i18n.str`Failed to validate the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_UNKNOWN: { + setNotif({ + message: i18n.str`Failed to validate the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_TOO_MANY_ATTEMPTS: { + setNotif({ + message: i18n.str`Failed to validate the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + default: { + assertUnreachable(resp); + } + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to verify code`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"> + <i18n.Translate>Validation code sent.</i18n.Translate> + </p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {(function () { + switch (challenge.challenge_type) { + case TanChannel.SMS: + return ( + <i18n.Translate> + The verification code sent to the phone number starting + with "<b>{challenge.address_hint}</b>" + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + The verification code sent to the email address starting + with "<b>{challenge.address_hint}</b>" + </i18n.Translate> + ); + default: + assertUnreachable(challenge.challenge_type); + } + })()} + <FormProvider<Form> + name="settings" + errors={errors} + object={value} + valueHandler={valueHandler} + > + <Input<Form> label={i18n.str`Verification code`} name="code" /> + </FormProvider> + </section> + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Back</i18n.Translate> + </button> + <AsyncButton + type="is-info" + disabled={errors !== undefined} + onClick={doVerificationImpl} + > + <i18n.Translate>Verify</i18n.Translate> + </AsyncButton> + </footer> + </div> + </div> + </div> + </Fragment> + ); +} + +export function SolveMFAChallenges({ + currentChallenge, + onCompleted, + onCancel, +}: Props): VNode { + const { i18n } = useTranslationContext(); + const { state: session, lib, logIn } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + const [solved, setSolved] = useState<string[]>([]); + const [selected, setSelected] = useState<Challenge>(); + + if (selected) { + return ( + <SolveChallenge + onCancel={() => setSelected(undefined)} + challenge={selected} + onSolved={() => { + setSelected(undefined); + setSolved([...solved, selected.challenge_id]); + }} + /> + ); + } + + const hasSolvedEnough = currentChallenge.combi_and + ? solved.length === currentChallenge.challenges.length + : solved.length > 0; + + async function doSendCodeImpl(ch: Challenge) { + try { + const resp = await lib.instance.sendChallenge(ch.challenge_id); + if (resp.case === "ok") { + return setSelected(ch); + } + switch (resp.case) { + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case HttpStatusCode.Forbidden: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_UNKNOWN: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_MFA_HELPER_EXEC_FAILED: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_CHALLENGE_SOLVED: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case TalerErrorCode.MERCHANT_TAN_TOO_EARLY: { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + default: { + assertUnreachable(resp) + } + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to send the verification code.`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + return ( + <Fragment> + <NotificationCard notification={notif} /> + + <div class="columns is-centered" style={{ margin: "auto" }}> + <div class="column is-two-thirds "> + <div class="modal-card" style={{ width: "100%", margin: 0 }}> + <header + class="modal-card-head" + style={{ border: "1px solid", borderBottom: 0 }} + > + <p class="modal-card-title"> + <i18n.Translate> + Multi-factor authentication required. + </i18n.Translate> + </p> + </header> + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {currentChallenge.combi_and ? ( + <i18n.Translate> + You need to complete all of this requirements. + </i18n.Translate> + ) : ( + <i18n.Translate> + You need to complete at least one of this requirements. + </i18n.Translate> + )} + </section> + {currentChallenge.challenges.map((d) => { + return ( + <section + class="modal-card-body" + style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} + > + {(function () { + switch (d.challenge_type) { + case TanChannel.SMS: + return ( + <i18n.Translate> + An SMS to the phone numbre starting with{" "} + {d.address_hint} + </i18n.Translate> + ); + case TanChannel.EMAIL: + return ( + <i18n.Translate> + An email to the address starting with{" "} + {d.address_hint} + </i18n.Translate> + ); + default: + assertUnreachable(d.challenge_type); + } + })()} + + <div + style={{ + justifyContent: "space-between", + display: "flex", + }} + > + <button + disabled={ + hasSolvedEnough || solved.indexOf(d.challenge_id) !== -1 + } + class="button" + onClick={() => { + setSelected(d); + }} + > + <i18n.Translate>I have a code</i18n.Translate> + </button> + <AsyncButton + disabled={ + hasSolvedEnough || solved.indexOf(d.challenge_id) !== -1 + } + onClick={() => doSendCodeImpl(d)} + > + <i18n.Translate>Send me a message</i18n.Translate> + </AsyncButton> + </div> + </section> + ); + })} + <footer + class="modal-card-foot " + style={{ + justifyContent: "space-between", + border: "1px solid", + borderTop: 0, + }} + > + <button class="button" onClick={onCancel}> + <i18n.Translate>Cancel</i18n.Translate> + </button> + <AsyncButton + type="is-info" + disabled={!hasSolvedEnough} + onClick={async () => onCompleted(solved)} + > + <i18n.Translate>Complete</i18n.Translate> + </AsyncButton> + </footer> + </div> + </div> + </div> + </Fragment> + ); +} diff --git a/packages/merchant-backoffice-ui/src/components/notifications/index.tsx b/packages/merchant-backoffice-ui/src/components/notifications/index.tsx @@ -21,6 +21,7 @@ import { h, VNode } from "preact"; import { MessageType, Notification } from "../../utils/types.js"; +import { assertUnreachable } from "@gnu-taler/taler-util"; interface Props { notifications: Notification[]; @@ -37,8 +38,9 @@ function messageStyle(type: MessageType): string { return "message is-danger"; case "SUCCESS": return "message is-success"; - default: - return "message"; + default: { + assertUnreachable(type); + } } } diff --git a/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/create/index.tsx @@ -17,7 +17,11 @@ * * @author Sebastian Javier Marchano (sebasjm) */ -import { HttpStatusCode, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + ChallengeResponse, + HttpStatusCode, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -27,6 +31,7 @@ import { usePreference } from "../../../hooks/preference.js"; import { Notification } from "../../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../../login/index.js"; import { CreatePage } from "./CreatePage.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; interface Props { onBack?: () => void; @@ -39,7 +44,66 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { lib, state, logIn } = useSessionContext(); - const [settings] = usePreference(); + const [currentChallenge, setCurrentChallenge] = useState< + | [ChallengeResponse, TalerMerchantApi.InstanceConfigurationMessage] + | undefined + >(); + + async function doCreateImpl( + d: TalerMerchantApi.InstanceConfigurationMessage, + challengeIds: string[] | undefined, + ) { + if (state.status !== "loggedIn") return; + try { + const resp = await lib.instance.createInstance(state.token, d, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge([resp.body, d]); + return; + } + + setNotif({ + message: i18n.str`Failed to create instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + if (d.auth.password) { + //if auth has been updated, request a new access token + const result = await lib.instance.createAccessToken( + d.id, + d.auth.password, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Instance created`), + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(state.instance, token); + } + } + onConfirm(); + } catch (error) { + setNotif({ + message: i18n.str`Failed to create instance`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => doCreateImpl(currentChallenge[1], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } return ( <Fragment> @@ -48,44 +112,7 @@ export default function Create({ onBack, onConfirm, forceId }: Props): VNode { <CreatePage onBack={onBack} forceId={forceId} - onCreate={async (d: TalerMerchantApi.InstanceConfigurationMessage) => { - if (state.status !== "loggedIn") return; - try { - const resp = await lib.instance.createInstance(state.token, d); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!") - } - - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - if (d.auth.password) { - //if auth has been updated, request a new access token - const result = await lib.instance.createAccessToken( - d.id, - d.auth.password, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Instace created`), - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(state.instance, token); - } - } - onConfirm(); - } catch (error) { - setNotif({ - message: i18n.str`Failed to create instance`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - } - }} + onCreate={(d) => doCreateImpl(d, undefined)} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx b/packages/merchant-backoffice-ui/src/paths/admin/list/index.tsx @@ -20,6 +20,7 @@ */ import { + ChallengeResponse, HttpStatusCode, TalerError, TalerMerchantApi, @@ -37,6 +38,7 @@ import { useBackendInstances } from "../../../hooks/instance.js"; import { Notification } from "../../../utils/types.js"; import { LoginPage } from "../../login/index.js"; import { View } from "./View.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; interface Props { onCreate: () => void; @@ -51,15 +53,14 @@ export default function Instances({ onChangePassword, }: Props): VNode { const result = useBackendInstances(); - const [deleting, setDeleting] = useState<TalerMerchantApi.Instance | null>( - null, - ); - const [purging, setPurging] = useState<TalerMerchantApi.Instance | null>( - null, - ); + const [deleting, setDeleting] = useState<TalerMerchantApi.Instance>(); + const [purging, setPurging] = useState<boolean>(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { state, lib } = useSessionContext(); + const [currentChallenge, setCurrentChallenge] = useState< + ChallengeResponse | undefined + >(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -76,100 +77,91 @@ export default function Instances({ } } + async function doDeleteImpl( + challengeIds: undefined | string[], + ): Promise<void> { + if (state.status !== "loggedIn") { + return; + } + try { + const resp = await lib.instance.deleteInstance( + state.token, + deleting!.id, + { challengeIds }, + ); + if (resp.type === "ok") { + setNotif({ + message: i18n.str`Instance "${deleting!.name}" (ID: ${ + deleting!.id + }) has been deleted`, + type: "SUCCESS", + }); + } else { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge(resp.body); + return; + } + setNotif({ + message: i18n.str`Failed to delete instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete instance`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + // pushNotification({message: 'delete_error', type: 'ERROR' }) + } + setDeleting(undefined); + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doDeleteImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + return ( <Fragment> <NotificationCard notification={notif} /> <View instances={result.body.instances} - onDelete={setDeleting} + onDelete={(d) => { + setDeleting(d); + setPurging(false); + }} onCreate={onCreate} - onPurge={setPurging} + onPurge={(d) => { + setDeleting(d); + setPurging(true); + }} onUpdate={onUpdate} onChangePassword={onChangePassword} selected={!!deleting} /> - {deleting && ( - <DeleteModal - element={deleting} - onCancel={() => setDeleting(null)} - onConfirm={async (): Promise<void> => { - if (state.status !== "loggedIn") { - return; - } - try { - const resp = await lib.instance.deleteInstance( - state.token, - deleting.id, - ); - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Instance "${deleting.name}" (ID: ${deleting.id}) has been deleted`, - type: "SUCCESS", - }); - } else { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to delete instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - // pushNotification({message: 'delete_error', type: 'ERROR' }) - } - setDeleting(null); - }} - /> - )} - {purging && ( - <PurgeModal - element={purging} - onCancel={() => setPurging(null)} - onConfirm={async (): Promise<void> => { - if (state.status !== "loggedIn") { - return; - } - try { - const resp = await lib.instance.deleteInstance( - state.token, - purging.id, - { - purge: true, - }, - ); - if (resp.type === "ok") { - setNotif({ - message: i18n.str`Instance '${purging.name}' (ID: ${purging.id}) has been purged`, - type: "SUCCESS", - }); - } else { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - setNotif({ - message: i18n.str`Failed to purge instance`, - type: "ERROR", - description: resp.detail?.hint, - }); - } - } catch (error) { - setNotif({ - message: i18n.str`Failed to purge instance`, - type: "ERROR", - description: error instanceof Error ? error.message : undefined, - }); - } - setPurging(null); - }} - /> - )} + {deleting && + (purging ? ( + <PurgeModal + element={deleting} + onCancel={() => setDeleting(undefined)} + onConfirm={() => doDeleteImpl(undefined)} + /> + ) : ( + <DeleteModal + element={deleting} + onCancel={() => setDeleting(undefined)} + onConfirm={() => doDeleteImpl(undefined)} + /> + ))} </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accessTokens/create/index.tsx @@ -19,7 +19,12 @@ * @author Sebastian Javier Marchano (sebasjm) */ -import { AbsoluteTime, HttpStatusCode, TalerMerchantApi } from "@gnu-taler/taler-util"; +import { + AbsoluteTime, + ChallengeResponse, + HttpStatusCode, + TalerMerchantApi, +} from "@gnu-taler/taler-util"; import { Time, useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -28,6 +33,7 @@ import { useSessionContext } from "../../../../context/session.js"; import { Notification } from "../../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; import { ConfirmModal, Row } from "../../../../components/modal/index.js"; +import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; export type Entity = TalerMerchantApi.LoginTokenRequest; interface Props { @@ -44,6 +50,60 @@ export default function AccessTokenCreatePage({ const { i18n } = useTranslationContext(); const [ok, setOk] = useState<{ token: string; expiration: AbsoluteTime }>(); + const [currentChallenge, setCurrentChallenge] = useState< + [ChallengeResponse, pwd: string, req: Entity] | undefined + >(); + + async function doCreateImpl( + pwd: string, + request: Entity, + challengeIds: string[] | undefined, + ) { + try { + const resp = await lib.instance.createAccessToken( + state.instance, + pwd, + request, + { challengeIds }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge([resp.body, pwd, request]); + return; + } + setNotif({ + message: i18n.str`Could not create access token`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + setOk({ + expiration: AbsoluteTime.fromProtocolTimestamp(resp.body.expiration), + token: resp.body.access_token, + }); + } catch (error) { + setNotif({ + message: i18n.str`Could not create access token`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => + doCreateImpl(currentChallenge[1], currentChallenge[2], ids) + } + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } return ( <Fragment> @@ -94,37 +154,7 @@ export default function AccessTokenCreatePage({ )} <CreatePage onBack={onBack} - onCreate={async (pwd: string, request: Entity) => { - return lib.instance - .createAccessToken(state.instance, pwd, request) - .then((resp) => { - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - setOk({ - expiration: AbsoluteTime.fromProtocolTimestamp( - resp.body.expiration, - ), - token: resp.body.access_token, - }); - }) - .catch((error) => { - setNotif({ - message: i18n.str`Could not create access token`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - }); - }} + onCreate={(a, b) => doCreateImpl(a, b, undefined)} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/create/index.tsx @@ -21,6 +21,7 @@ import { AccessToken, + ChallengeResponse, FacadeCredentials, HttpStatusCode, OperationFail, @@ -42,7 +43,11 @@ import { useSessionContext } from "../../../../context/session.js"; import { Notification } from "../../../../utils/types.js"; import { CreatePage } from "./CreatePage.js"; import { BasicOrTokenAuth } from "@gnu-taler/taler-util"; -import type { HttpRequestLibrary, HeadersImpl } from "@gnu-taler/taler-util/http"; +import type { + HttpRequestLibrary, + HeadersImpl, +} from "@gnu-taler/taler-util/http"; +import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; export type Entity = TalerMerchantApi.AccountAddDetails; interface Props { @@ -53,40 +58,60 @@ interface Props { export default function CreateValidator({ onConfirm, onBack }: Props): VNode { const { state, lib } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); - // const [tested, setTested] = useState(false); + const { i18n } = useTranslationContext(); + const [currentChallenge, setCurrentChallenge] = useState< + [ChallengeResponse, Entity] | undefined + >(); + + async function doCreateImpl( + request: Entity, + challengeIds: string[] | undefined, + ) { + try { + const resp = await lib.instance.addBankAccount(state.token, request, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge([resp.body, request]); + return; + } + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + onConfirm(); + } catch (error) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => doCreateImpl(currentChallenge[1], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } return ( <> <NotificationCard notification={notif} /> <CreatePage onBack={onBack} - onCreate={async (request: Entity) => { - return lib.instance - .addBankAccount(state.token, request) - .then((resp) => { - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!") - } - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - onConfirm(); - }) - .catch((error) => { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - }); - }} + onCreate={(e) => doCreateImpl(e, undefined)} /> </> ); @@ -102,17 +127,14 @@ export async function testRevenueAPI( creds: FacadeCredentials | undefined, ): Promise< | OperationOk<PaytoString> + | OperationFail<TestRevenueErrorType.CANT_VALIDATE> | OperationFail<HttpStatusCode.NotFound> | OperationFail<HttpStatusCode.Unauthorized> - | OperationFail<TestRevenueErrorType.CANT_VALIDATE> | OperationFail<HttpStatusCode.BadRequest> | TalerError > { const httpLib: HttpRequestLibrary = new BrowserFetchHttpLib(); - const api = new TalerRevenueHttpClient( - revenueAPI.href, - httpLib, - ); + const api = new TalerRevenueHttpClient(revenueAPI.href, httpLib); const auth: BasicOrTokenAuth | undefined = creds === undefined ? undefined diff --git a/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/accounts/update/index.tsx @@ -20,6 +20,7 @@ */ import { + ChallengeResponse, HttpStatusCode, TalerError, TalerMerchantApi, @@ -38,6 +39,7 @@ import { LoginPage } from "../../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; import { WithId } from "../../../../declaration.js"; +import { SolveMFAChallenges } from "../../../../components/SolveMFA.js"; export type Entity = TalerMerchantApi.AccountPatchDetails & WithId; @@ -56,6 +58,9 @@ export default function UpdateValidator({ const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); + const [currentChallenge, setCurrentChallenge] = useState< + [ChallengeResponse, TalerMerchantApi.BankAccountDetail, TalerMerchantApi.AccountAddDetails] | undefined + >(); if (!result) return <Loading />; if (result instanceof TalerError) { @@ -75,82 +80,102 @@ export default function UpdateValidator({ } } + async function doUpdateImpl(request: TalerMerchantApi.AccountPatchDetails) { + return lib.instance + .updateBankAccount(state.token, bid, request) + .then((resp) => { + if (resp.type === "fail") { + setNotif({ + message: i18n.str`Could not update account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + onConfirm(); + }) + .catch((error) => { + setNotif({ + message: i18n.str`Could not update account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + }); + } + + async function doReplaceImpl( + prev: TalerMerchantApi.BankAccountDetail, + next: TalerMerchantApi.AccountAddDetails, + challengeIds: undefined | string[], + ) { + try { + const resp = await lib.instance.addBankAccount(state.token, next, { + challengeIds, + }); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge([resp.body, prev, next]); + return; + } + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + } catch (error) { + setNotif({ + message: i18n.str`Could not create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + return; + } + try { + const resp = await lib.instance.deleteBankAccount( + state.token, + prev.h_wire, + ); + if (resp.type === "fail") { + setNotif({ + message: i18n.str`Could not delete account`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + } catch (error) { + setNotif({ + message: i18n.str`Could not delete account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + return; + } + onConfirm(); + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => doReplaceImpl(currentChallenge[1], currentChallenge[2], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + return ( <Fragment> <NotificationCard notification={notif} /> <UpdatePage account={{ ...result.body, id: bid }} onBack={onBack} - onUpdate={async (request) => { - return lib.instance - .updateBankAccount(state.token, bid, request) - .then((resp) => { - if (resp.type === "fail") { - setNotif({ - message: i18n.str`Could not update account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - onConfirm(); - }) - .catch((error) => { - setNotif({ - message: i18n.str`Could not update account`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - }); - }} - onReplace={async (prev, next) => { - try { - const resp = await lib.instance.addBankAccount(state.token, next); - if (resp.type === "fail") { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - } catch (error) { - setNotif({ - message: i18n.str`Could not create account`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - return; - } - try { - const resp = await lib.instance.deleteBankAccount( - state.token, - prev.h_wire, - ); - if (resp.type === "fail") { - setNotif({ - message: i18n.str`Could not delete account`, - type: "ERROR", - description: resp.detail?.hint, - }); - return; - } - } catch (error) { - setNotif({ - message: i18n.str`Could not delete account`, - type: "ERROR", - description: - error instanceof Error ? error.message : String(error), - }); - return; - } - onConfirm(); - }} + onUpdate={doUpdateImpl} + onReplace={(prev, next) => doReplaceImpl(prev, next, undefined)} /> </Fragment> ); diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/DetailPage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/DetailPage.tsx @@ -34,7 +34,8 @@ import { undefinedIfEmpty } from "../../../utils/table.js"; interface Props { instanceId: string; hasPassword: boolean | undefined; - onNewPassword: (c: string | undefined, s: string) => void; + onNewPassword: (s: string) => void; + // onNewPassword: (c: string | undefined, s: string) => void; onBack?: () => void; } @@ -44,23 +45,27 @@ export function DetailPage({ onBack, onNewPassword, }: Props): VNode { - type State = { old_token: string; new_token: string; repeat_token: string }; + type State = { + // old_token: string; + new_token: string; + repeat_token: string; + }; const [form, setValue] = useState<Partial<State>>({ - old_token: "", + // old_token: "", new_token: "", repeat_token: "", }); const { i18n } = useTranslationContext(); const errors = undefinedIfEmpty({ - old_token: - hasPassword && !form.old_token - ? i18n.str`You need your password to perform the operation` - : undefined, + // old_token: + // hasPassword && !form.old_token + // ? i18n.str`You need your password to perform the operation` + // : undefined, new_token: !form.new_token ? i18n.str`Required` - : form.new_token === form.old_token - ? i18n.str`Can't be the same as the old password` + // : form.new_token === form.old_token + // ? i18n.str`Can't be the same as the old password` : undefined, repeat_token: form.new_token !== form.repeat_token @@ -74,10 +79,11 @@ export function DetailPage({ async function submitForm() { if (hasErrors) return; - const oldToken = - form.old_token !== undefined && hasPassword ? form.old_token : undefined; + // const oldToken = + // form.old_token !== undefined && hasPassword ? form.old_token : undefined; const newToken = form.new_token!; - onNewPassword(oldToken, newToken); + onNewPassword(newToken); + // onNewPassword(oldToken, newToken); } return ( @@ -101,7 +107,7 @@ export function DetailPage({ <div class="column is-four-fifths"> <FormProvider errors={errors} object={form} valueHandler={setValue}> <Fragment> - {hasPassword && ( + {/* {hasPassword && ( <Fragment> <Input<State> name="old_token" @@ -110,7 +116,7 @@ export function DetailPage({ inputType="password" /> </Fragment> - )} + )} */} <Input<State> name="new_token" diff --git a/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/password/index.tsx @@ -14,6 +14,7 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ import { + ChallengeResponse, HttpStatusCode, MerchantAuthMethod, TalerError, @@ -40,6 +41,7 @@ import { } from "../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../notfound/index.js"; import { DetailPage } from "./DetailPage.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; export interface Props { onChange: () => void; @@ -53,23 +55,28 @@ export default function PasswordPage(props: Props): VNode { const { i18n } = useTranslationContext(); const [settings] = usePreference(); + const [currentChallenge, setCurrentChallenge] = useState< + [ChallengeResponse, string] | undefined + >(); + async function changePassword( - currentPassword: string | undefined, + // currentPassword: string | undefined, newPassword: string, + challengeIds: undefined | string[], ) { - if (currentPassword) { - const resp = await lib.instance.createAccessToken( - instanceId, - currentPassword, - TEMP_TEST_TOKEN(i18n.str`Testing password`), - ); - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - if (resp.type !== "ok") { - throw Error(resp.detail?.hint ?? "The current password is wrong"); - } - } + // if (currentPassword) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // currentPassword, + // TEMP_TEST_TOKEN(i18n.str`Testing password`), + // ); + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // if (resp.type !== "ok") { + // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // } + // } { const resp = await lib.instance.updateCurrentInstanceAuthentication( @@ -78,29 +85,42 @@ export default function PasswordPage(props: Props): VNode { password: newPassword, method: MerchantAuthMethod.TOKEN, }, + { challengeIds }, ); if (resp.type === "fail") { if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); + setCurrentChallenge([resp.body, newPassword]); + return; } throw Error(resp.detail?.hint ?? "The request failed"); } } - const resp = await lib.instance.createAccessToken( - instanceId, - newPassword, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), + // const resp = await lib.instance.createAccessToken( + // instanceId, + // newPassword, + // FOREVER_REFRESHABLE_TOKEN(i18n.str`Password changed`), + // ); + // if (resp.type === "ok") { + // logIn(state.instance, resp.body.access_token); + // return; + // } else { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The new login failed"); + // } + } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => changePassword(currentChallenge[1], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> ); - if (resp.type === "ok") { - logIn(state.instance, resp.body.access_token); - return; - } else { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - throw Error(resp.detail?.hint ?? "The new login failed"); - } } return CommonPassword({ ...props, instanceId }, result, changePassword); @@ -116,23 +136,28 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { const instanceId = props.instanceId; + const [currentChallenge, setCurrentChallenge] = useState< + [ChallengeResponse, string] | undefined + >(); + async function changePassword( - currentPassword: string | undefined, + // currentPassword: string | undefined, newPassword: string, + challengeIds: undefined | string[], ) { - if (currentPassword) { - const resp = await lib.instance.createAccessToken( - instanceId, - currentPassword, - TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), - ); - if (resp.type !== "ok") { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - throw Error(resp.detail?.hint ?? "The current password is wrong"); - } - } + // if (currentPassword) { + // const resp = await lib.instance.createAccessToken( + // instanceId, + // currentPassword, + // TEMP_TEST_TOKEN(i18n.str`Testing password for instance ${instanceId}`), + // ); + // if (resp.type !== "ok") { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The current password is wrong"); + // } + // } { const resp = await lib.instance.updateInstanceAuthentication( @@ -142,27 +167,41 @@ export function AdminPassword(props: Props & { instanceId: string }): VNode { password: newPassword, method: MerchantAuthMethod.TOKEN, }, + { challengeIds }, ); if (resp.type === "fail") { throw Error(resp.detail?.hint ?? "The request failed"); } } - const resp = await subInstanceLib.createAccessToken( - instanceId, - newPassword, - FOREVER_REFRESHABLE_TOKEN( - i18n.str`Password changed for instance ${instanceId}`, - ), + // const resp = await subInstanceLib.createAccessToken( + // instanceId, + // newPassword, + // FOREVER_REFRESHABLE_TOKEN( + // i18n.str`Password changed for instance ${instanceId}`, + // ), + // ); + // if (resp.type === "ok") { + // return; + // } else { + // if (resp.case === HttpStatusCode.Accepted) { + // throw Error("FIXME!!!!"); + // } + // throw Error(resp.detail?.hint ?? "The new login failed"); + // } + } + + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => changePassword(currentChallenge[1], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> ); - if (resp.type === "ok") { - return; - } else { - if (resp.case === HttpStatusCode.Accepted) { - throw Error("FIXME!!!!"); - } - throw Error(resp.detail?.hint ?? "The new login failed"); - } } + return CommonPassword(props, result, changePassword); } @@ -173,8 +212,9 @@ function CommonPassword( | TalerError | undefined, onNewPassword: ( - oldToken: string | undefined, + // oldToken: string | undefined, newToken: string, + challengeIds: undefined | string[], ) => Promise<void>, ): VNode { const { i18n } = useTranslationContext(); @@ -213,9 +253,11 @@ function CommonPassword( onBack={onCancel} instanceId={result.body.name} hasPassword={hasToken} - onNewPassword={async (currentPassword, newPassword): Promise<void> => { + onNewPassword={async (newPassword): Promise<void> => { + // onNewPassword={async (currentPassword, newPassword): Promise<void> => { try { - await onNewPassword(currentPassword, newPassword); + // await onNewPassword(currentPassword, newPassword); + await onNewPassword(newPassword, undefined); return onChange(); } catch (error) { return setNotif({ diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -20,25 +20,25 @@ */ import { + assertUnreachable, + ChallengeResponse, Duration, HttpStatusCode, - InternationalizationAPI, LoginTokenRequest, LoginTokenScope, - TranslatedString, + TranslatedString } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; -import { Fragment, VNode, h } from "preact"; +import { Fragment, h, VNode } from "preact"; import { useState } from "preact/hooks"; import { AsyncButton } from "../../components/exception/AsyncButton.js"; import { NotificationCard } from "../../components/menu/index.js"; +import { SolveMFAChallenges } from "../../components/SolveMFA.js"; import { useSessionContext } from "../../context/session.js"; -import { Notification } from "../../utils/types.js"; -import { format } from "date-fns"; import { - datetimeFormatForSettings, - usePreference, + usePreference } from "../../hooks/preference.js"; +import { Notification } from "../../utils/types.js"; interface Props {} @@ -63,16 +63,23 @@ export function LoginPage(_p: Props): VNode { const { state, logIn, getInstanceForUsername, config } = useSessionContext(); const [username, setUsername] = useState(state.instance); const [settings] = usePreference(); + const [currentChallenge, setCurrentChallenge] = useState< + ChallengeResponse | undefined + >(); + const { i18n } = useTranslationContext(); - async function doLoginImpl() { + async function doLoginImpl(challengeIds: string[] | undefined) { const api = getInstanceForUsername(username); const result = await api.createAccessToken( username, password, FOREVER_REFRESHABLE_TOKEN(i18n.str`Logged in`), + { + challengeIds + } ); if (result.type === "ok") { const { access_token: token } = result.body; @@ -94,10 +101,29 @@ export function LoginPage(_p: Props): VNode { }); return; } + case HttpStatusCode.Accepted: { + setCurrentChallenge(result.body) + return; + } + default: { + assertUnreachable(result) + } } } } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doLoginImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + return ( <Fragment> <NotificationCard notification={notif} /> @@ -135,7 +161,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"instance name"} name="username" onKeyPress={(e) => - e.keyCode === 13 ? doLoginImpl() : null + e.keyCode === 13 ? doLoginImpl(undefined) : null } value={username} onInput={(e): void => @@ -161,7 +187,7 @@ export function LoginPage(_p: Props): VNode { placeholder={"current password"} name="token" onKeyPress={(e) => - e.keyCode === 13 ? doLoginImpl() : null + e.keyCode === 13 ? doLoginImpl(undefined) : null } value={password} onInput={(e): void => @@ -184,21 +210,21 @@ export function LoginPage(_p: Props): VNode { {!config.have_self_provisioning ? ( <div /> ) : ( - <a href="#/account/reset" class="button " disabled={!username}> + <a href={`#/account/reset/${username}`} class="button " disabled={!username}> <i18n.Translate>Forgot password</i18n.Translate> </a> )} <AsyncButton type="is-info" disabled={!username || !password} - onClick={doLoginImpl} + onClick={() => doLoginImpl(undefined)} > <i18n.Translate>Confirm</i18n.Translate> </AsyncButton> </footer> </div> <div> - <a href={"#/account/reset"} class="has-icon"> + <a href={"#/account/new"} class="has-icon"> <span class="icon"> <i class="mdi mdi-account-plus" /> </span> diff --git a/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/newAccount/index.tsx @@ -14,7 +14,11 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; +import { + ChallengeResponse, + HttpStatusCode, + MerchantAuthMethod, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; @@ -34,6 +38,7 @@ import { } from "../../utils/constants.js"; import { NotificationCard } from "../../components/menu/index.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; +import { SolveMFAChallenges } from "../../components/SolveMFA.js"; export interface Account { id: string; @@ -51,8 +56,12 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { const { i18n } = useTranslationContext(); const { state: session, lib, logIn } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const [currentChallenge, setCurrentChallenge] = useState< + ChallengeResponse | undefined + >(); - const [value, setValue] = useState<Partial<Account>>({}); + const [value, setValue] = useState<Partial<Account>>({ + }); const errors: FormErrors<Account> = { id: !value.id ? i18n.str`Required` @@ -93,35 +102,34 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { setValue(v); } - async function doCreateImpl() { + async function doCreateImpl(challengeIds: string[] | undefined) { try { - const resp = await lib.instance.createInstanceSelfProvision({ - address: {}, - auth: { - method: MerchantAuthMethod.TOKEN, - password: value.password!, - }, - default_pay_delay: { - d_us: 1000, - }, - default_wire_transfer_delay: { - d_us: 1000, + const resp = await lib.instance.createInstanceSelfProvision( + { + address: {}, + auth: { + method: MerchantAuthMethod.TOKEN, + password: value.password!, + }, + default_pay_delay: { + d_us: 1000, + }, + default_wire_transfer_delay: { + d_us: 1000, + }, + id: value.id!, + jurisdiction: {}, + name: value.name!, + use_stefan: true, + email: value.email!, + phone_number: value.phone, }, - id: value.id!, - jurisdiction: {}, - name: value.name!, - use_stefan: true, - email: value.email!, - phone_number: value.phone, - }); + { challengeIds }, + ); if (resp.type === "fail") { if (resp.case === HttpStatusCode.Accepted) { - setNotif({ - message: i18n.str`The account was created`, - type: "INFO", - description: i18n.str`To complete the process you need to confirm email and phone.`, - }); + setCurrentChallenge(resp.body); } else { setNotif({ message: i18n.str`Failed to create account`, @@ -131,16 +139,6 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { } return; } - //if auth has been updated, request a new access token - const result = await lib.instance.createAccessToken( - value.id!, - value.password!, - FOREVER_REFRESHABLE_TOKEN(i18n.str`Account created`), - ); - if (result.type === "ok") { - const { access_token: token } = result.body; - logIn(value.id!, token); - } onCreated(); } catch (error) { setNotif({ @@ -151,6 +149,18 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { } } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doCreateImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + return ( <Fragment> <NotificationCard notification={notif} /> @@ -228,7 +238,7 @@ export function NewAccount({ onCancel, onCreated }: Props): VNode { <AsyncButton type="is-info" disabled={!errors} - onClick={doCreateImpl} + onClick={() => doCreateImpl(undefined)} > <i18n.Translate>Create</i18n.Translate> </AsyncButton> diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -14,95 +14,123 @@ GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> */ -import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util"; +import { + ChallengeResponse, + HttpStatusCode, + MerchantAuthMethod, +} from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, VNode, h } from "preact"; import { useState } from "preact/hooks"; -import { Notification } from "../../utils/types.js"; import { AsyncButton } from "../../components/exception/AsyncButton.js"; import { FormErrors, FormProvider, } from "../../components/form/FormProvider.js"; import { Input } from "../../components/form/Input.js"; -import { InputWithAddon } from "../../components/form/InputWithAddon.js"; -import { useSessionContext } from "../../context/session.js"; -import { - EMAIL_REGEX, - INSTANCE_ID_REGEX, - PHONE_JUST_NUMBERS_REGEX, -} from "../../utils/constants.js"; import { NotificationCard } from "../../components/menu/index.js"; +import { SolveMFAChallenges } from "../../components/SolveMFA.js"; +import { useSessionContext } from "../../context/session.js"; +import { Notification } from "../../utils/types.js"; import { FOREVER_REFRESHABLE_TOKEN } from "../login/index.js"; -export interface Account { - code: string; +interface Form { + password: string; + repeat: string; } interface Props { onCancel: () => void; - instanceId: string; + id: string; onReseted: () => void; } -export function ResetAccount({ onCancel, onReseted, instanceId }: Props): VNode { + +export function ResetAccount({ + onCancel, + onReseted, + id: instanceId, +}: Props): VNode { const { i18n } = useTranslationContext(); const { state: session, lib, logIn } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); + const [value, setValue] = useState<Partial<Form>>({ + password: "asd", + repeat: "asd", + }); + const [currentChallenge, setCurrentChallenge] = useState< + ChallengeResponse | undefined + >(); - const [value, setValue] = useState<Partial<Account>>({}); - const errors: FormErrors<Account> = { - code: !value.code + const errors: FormErrors<Form> = { + password: !value.password ? i18n.str`Required` : undefined, + repeat: !value.repeat ? i18n.str`Required` - : undefined, + : value.password !== value.repeat + ? i18n.str`Doesn't match` + : undefined, }; - function valueHandler(s: (d: Partial<Account>) => Partial<Account>): void { + function valueHandler(s: (d: Partial<Form>) => Partial<Form>): void { const next = s(value); - const v: Account = { - code: next.code ?? "", + const v: Form = { + password: next.password ?? "", + repeat: next.repeat ?? "", }; setValue(v); } - async function doResetImpl() { - // try { - // const resp = await lib.instance.forgotPasswordSelfProvision({ - - // }); + async function doResetImpl(challengeIds: string[] | undefined) { + try { + const resp = await lib.instance.forgotPasswordSelfProvision( + { + method: MerchantAuthMethod.TOKEN, + password: value.password!, + }, + { + challengeIds, + }, + ); + if (resp.type === "fail") { + if (resp.case === HttpStatusCode.Accepted) { + setCurrentChallenge(resp.body); + } else { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: resp.detail?.hint, + }); + } + return; + } + //if auth has been updated, request a new access token + const result = await lib.instance.createAccessToken( + instanceId, + value.password!, + FOREVER_REFRESHABLE_TOKEN(i18n.str`Password reseted`), + ); + if (result.type === "ok") { + const { access_token: token } = result.body; + logIn(instanceId, token); + } + onReseted(); + } catch (error) { + setNotif({ + message: i18n.str`Failed to create account`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); + } + } - // if (resp.type === "fail") { - // if (resp.case === HttpStatusCode.Accepted) { - // setNotif({ - // message: i18n.str`The account was created`, - // type: "INFO", - // description: i18n.str`To complete the process you need to confirm email and phone.`, - // }); - // } else { - // setNotif({ - // message: i18n.str`Failed to create account`, - // type: "ERROR", - // description: resp.detail?.hint, - // }); - // } - // return; - // } - // //if auth has been updated, request a new access token - // const result = await lib.instance.createAccessToken( - // value.id!, - // value.password!, - // FOREVER_REFRESHABLE_TOKEN(i18n.str`Account created`), - // ); - // if (result.type === "ok") { - // const { access_token: token } = result.body; - // logIn(value.id!, token); - // } - // onCreated(); - // } catch (error) { - // setNotif({ - // message: i18n.str`Failed to create account`, - // type: "ERROR", - // description: error instanceof Error ? error.message : String(error), - // }); - // } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doResetImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); } return ( @@ -117,23 +145,30 @@ export function ResetAccount({ onCancel, onReseted, instanceId }: Props): VNode style={{ border: "1px solid", borderBottom: 0 }} > <p class="modal-card-title"> - <i18n.Translate>Self provision</i18n.Translate> + <i18n.Translate> + Resetting access to the instance "{instanceId}" + </i18n.Translate> </p> </header> <section class="modal-card-body" style={{ border: "1px solid", borderTop: 0, borderBottom: 0 }} > - <FormProvider<Account> + <FormProvider<Form> name="settings" errors={errors} object={value} valueHandler={valueHandler} > - <Input<Account> - label={i18n.str`Code`} - tooltip={i18n.str`Code received by the challenge channel.`} - name="code" + <Input<Form> + label={i18n.str`New password`} + inputType="password" + name="password" + /> + <Input<Form> + label={i18n.str`Repeat password`} + inputType="password" + name="repeat" /> </FormProvider> </section> @@ -151,9 +186,9 @@ export function ResetAccount({ onCancel, onReseted, instanceId }: Props): VNode <AsyncButton type="is-info" disabled={!errors} - onClick={doResetImpl} + onClick={() => doResetImpl(undefined)} > - <i18n.Translate>Validate</i18n.Translate> + <i18n.Translate>Reset</i18n.Translate> </AsyncButton> </footer> </div> diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -77,7 +77,7 @@ import { opKnownAlternativeHttpFailure, opKnownHttpFailure, opKnownTalerFailure, - opUnknownHttpFailure + opUnknownHttpFailure, } from "@gnu-taler/taler-util"; import { HttpRequestLibrary, @@ -848,12 +848,12 @@ export class TalerMerchantInstanceHttpClient { ); return opSuccessFromHttp(resp, codecForAccountAddResponse()); } - case HttpStatusCode.Accepted: - return opKnownAlternativeHttpFailure( - resp, - resp.status, - codecForChallengeResponse(), - ); + case HttpStatusCode.Accepted: + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForChallengeResponse(), + ); case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -2533,7 +2533,7 @@ export class TalerMerchantInstanceHttpClient { const resp = await this.httpLib.fetch(url.href, { method: "POST", // FIXME: this should be removed - body: ({}) + body: {}, }); switch (resp.status) { case HttpStatusCode.NoContent: @@ -2661,6 +2661,8 @@ export class TalerMerchantInstanceHttpClient { codecForChallengeResponse(), ); } + case HttpStatusCode.Forbidden: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Unauthorized: return opKnownHttpFailure(resp.status, resp); default: @@ -2807,6 +2809,9 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp token: AccessToken | undefined, instanceId: string, body: TalerMerchantApi.InstanceAuthConfigurationMessage, + params: { + challengeIds?: string[]; + } = {}, ) { const url = new URL( `management/instances/${instanceId}/auth`, @@ -2817,6 +2822,9 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp if (token) { headers.Authorization = makeBearerTokenAuthHeader(token); } + if (params.challengeIds && params.challengeIds.length > 0) { + headers["Taler-Challenge-Ids"] = params.challengeIds.join(", "); + } const resp = await this.httpLib.fetch(url.href, { method: "POST", body, @@ -2824,6 +2832,8 @@ export class TalerMerchantManagementHttpClient extends TalerMerchantInstanceHttp }); switch (resp.status) { + case HttpStatusCode.Accepted: + return opEmptySuccess(); case HttpStatusCode.NoContent: return opEmptySuccess(); case HttpStatusCode.Unauthorized: // FIXME: missing in docs