taler-typescript-core

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

commit 74cbf5e77a517e9e0a6d577f10ee54f8760f0436
parent 78c6b7a6f792f9d1eb1544dc15949283e7852e3c
Author: Sebastian <sebasjm@gmail.com>
Date:   Mon, 15 Sep 2025 11:42:25 -0300

fixes #10373

Diffstat:
Apackages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackages/merchant-backoffice-ui/src/paths/instance/update/index.tsx | 130++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Mpackages/merchant-backoffice-ui/src/paths/login/index.tsx | 118+++++++++++++++++++++++++++++++++++++++++++------------------------------------
Mpackages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx | 7+++----
Mpackages/taler-util/src/http-client/merchant.ts | 14++++++++++++++
5 files changed, 375 insertions(+), 91 deletions(-)

diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/DeletePage.tsx @@ -0,0 +1,197 @@ +/* + This file is part of GNU Taler + (C) 2021-2024 Taler Systems S.A. + + GNU Taler is free software; you can redistribute it and/or modify it under the + terms of the GNU General Public License as published by the Free Software + Foundation; either version 3, or (at your option) any later version. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +/** + * + * @author Sebastian Javier Marchano (sebasjm) + */ + +import { + assertUnreachable, + ChallengeResponse, + HttpStatusCode, +} from "@gnu-taler/taler-util"; +import { useTranslationContext } from "@gnu-taler/web-util/browser"; +import { Fragment, h, VNode } from "preact"; +import { useState } from "preact/hooks"; +import { AsyncButton } from "../../../components/exception/AsyncButton.js"; +import { FormProvider } from "../../../components/form/FormProvider.js"; +import { Input } from "../../../components/form/Input.js"; +import { InputToggle } from "../../../components/form/InputToggle.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; +import { useSessionContext } from "../../../context/session.js"; +import { undefinedIfEmpty } from "../../../utils/table.js"; +import { Notification } from "../../../utils/types.js"; + +interface Props { + instanceId: string; + onDeleted: () => void; + onBack?: () => void; +} + +export function DeletePage({ instanceId, onBack, onDeleted }: Props): VNode { + type State = { + // old_token: string; + name: string; + purge: boolean; + }; + const [form, setValue] = useState<Partial<State>>({ + name: "", + purge: false, + }); + const { i18n } = useTranslationContext(); + const [currentChallenge, setCurrentChallenge] = useState< + ChallengeResponse | undefined + >(); + const { state: session, lib, logOut } = useSessionContext(); + const [notif, setNotif] = useState<Notification | undefined>(undefined); + + const errors = undefinedIfEmpty({ + name: !form.name + ? i18n.str`Required` + : form.name !== instanceId + ? i18n.str`It's not the same.` + : undefined, + }); + + const hasErrors = errors !== undefined; + + const text = i18n.str`You are deleting the instance with ID "${instanceId}"`; + + async function doDeleteImpl(challengeIds: undefined | string[]) { + if (hasErrors) return; + try { + const resp = await lib.instance.deleteCurrentInstance(session.token, { + purge: form.purge, + challengeIds, + }); + if (resp.type === "ok") { + logOut() + return onDeleted(); + } + switch (resp.case) { + case HttpStatusCode.Accepted: { + setCurrentChallenge(resp.body); + return; + } + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + case HttpStatusCode.Conflict: { + setNotif({ + message: i18n.str`Failed to delete the instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + return; + } + default: { + assertUnreachable(resp); + } + } + } catch (error) { + setNotif({ + message: i18n.str`Failed to delete the instance.`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); + } + } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doDeleteImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + + return ( + <div> + <section class="section"> + <section class="hero is-hero-bar"> + <div class="hero-body"> + <div class="level"> + <div class="level-left"> + <div class="level-item"> + <span class="is-size-4">{text}</span> + </div> + </div> + </div> + </div> + </section> + <hr /> + + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <FormProvider errors={errors} object={form} valueHandler={setValue}> + <Input<State> + name="name" + label={i18n.str`Instance`} + placeholder={instanceId} + help={i18n.str`Write the instance name to confirm the deletion`} + /> + <InputToggle<State> + name="purge" + label={i18n.str`Purge`} + tooltip={i18n.str`All the data will be fully deleted, otherwise only the access will be removed.`} + /> + <div class="buttons is-right mt-5"> + {onBack && ( + <a class="button" onClick={onBack}> + <i18n.Translate>Cancel</i18n.Translate> + </a> + )} + + <button + class="button is-small is-danger" + type="button" + disabled={hasErrors} + data-tooltip={ + hasErrors + ? i18n.str`Please complete the marked fields` + : i18n.str`Confirm operation` + } + onClick={() => doDeleteImpl(undefined)} + > + <i18n.Translate>DELETE</i18n.Translate> + </button> + </div> + </FormProvider> + </div> + <div class="column" /> + </div> + </section> + </div> + ); +} diff --git a/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx b/packages/merchant-backoffice-ui/src/paths/instance/update/index.tsx @@ -13,10 +13,16 @@ 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 { HttpStatusCode, TalerError, TalerMerchantApi, TalerMerchantInstanceHttpClient, TalerMerchantManagementResultByMethod, assertUnreachable } from "@gnu-taler/taler-util"; import { - useTranslationContext -} from "@gnu-taler/web-util/browser"; + ChallengeResponse, + HttpStatusCode, + TalerError, + TalerMerchantApi, + TalerMerchantInstanceHttpClient, + TalerMerchantManagementResultByMethod, + assertUnreachable, +} 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 { ErrorLoadingMerchant } from "../../../components/ErrorLoadingMerchant.js"; @@ -31,6 +37,8 @@ import { Notification } from "../../../utils/types.js"; import { LoginPage } from "../../login/index.js"; import { NotFoundPageOrAdminCreate } from "../../notfound/index.js"; import { UpdatePage } from "./UpdatePage.js"; +import { SolveMFAChallenges } from "../../../components/SolveMFA.js"; +import { DeletePage } from "./DeletePage.js"; export interface Props { onBack: () => void; @@ -39,50 +47,101 @@ export interface Props { export default function Update(props: Props): VNode { const { lib } = useSessionContext(); - const updateInstance = lib.instance.updateCurrentInstance.bind(lib.instance) + const updateInstance = lib.instance.updateCurrentInstance.bind(lib.instance); const result = useInstanceDetails(); - return CommonUpdate(props, result, updateInstance,); + return CommonUpdate(props, result, updateInstance); } export function AdminUpdate(props: Props & { instanceId: string }): VNode { const { lib } = useSessionContext(); const t = lib.subInstanceApi(props.instanceId).instance; - const updateInstance = t.updateCurrentInstance.bind(t) + const updateInstance = t.updateCurrentInstance.bind(t); const result = useManagedInstanceDetails(props.instanceId); - return CommonUpdate(props, result, updateInstance,); + return CommonUpdate(props, result, updateInstance); } - function CommonUpdate( - { - onBack, - onConfirm, - }: Props, - result: TalerMerchantManagementResultByMethod<"getInstanceDetails"> | TalerError | undefined, + { onBack, onConfirm }: Props, + result: + | TalerMerchantManagementResultByMethod<"getInstanceDetails"> + | TalerError + | undefined, updateInstance: typeof TalerMerchantInstanceHttpClient.prototype.updateCurrentInstance, ): VNode { const [notif, setNotif] = useState<Notification | undefined>(undefined); const { i18n } = useTranslationContext(); const { state } = useSessionContext(); + const [currentChallenge, setCurrentChallenge] = useState< + | [ChallengeResponse, TalerMerchantApi.InstanceReconfigurationMessage] + | undefined + >(); - if (!result) return <Loading /> + if (!result) return <Loading />; if (result instanceof TalerError) { - return <ErrorLoadingMerchant error={result} /> + return <ErrorLoadingMerchant error={result} />; } if (result.type === "fail") { - switch(result.case) { + switch (result.case) { case HttpStatusCode.Unauthorized: { - return <LoginPage /> + return <LoginPage />; } case HttpStatusCode.NotFound: { return <NotFoundPageOrAdminCreate />; } default: { - assertUnreachable(result) + assertUnreachable(result); + } + } + } + + async function doUpdateImpl( + d: TalerMerchantApi.InstanceReconfigurationMessage, + challengeIds: undefined | string[], + ) { + if (state.status !== "loggedIn") { + return; + } + try { + const resp = await updateInstance(state.token, d, { challengeIds }); + if (resp.type === "ok") { + return onConfirm(); + } + switch (resp.case) { + case HttpStatusCode.Accepted: { + setCurrentChallenge([resp.body, d]); + return; + } + case HttpStatusCode.Unauthorized: + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Failed to update instance`, + type: "ERROR", + description: resp.detail?.hint, + }); + } } + } catch (error) { + return setNotif({ + message: i18n.str`Failed to update instance`, + type: "ERROR", + description: error instanceof Error ? error.message : String(error), + }); } } + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge[0]} + onCompleted={(ids) => doUpdateImpl(currentChallenge[1], ids)} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + const [deleting, setDeleting] = useState<boolean>(); + return ( <Fragment> <NotificationCard notification={notif} /> @@ -90,23 +149,26 @@ function CommonUpdate( onBack={onBack} isLoading={false} selected={result.body} - onUpdate={( - d: TalerMerchantApi.InstanceReconfigurationMessage, - ): Promise<void> => { - if (state.status !== "loggedIn") { - return Promise.resolve(); - } - return updateInstance(state.token, d) - .then(onConfirm) - .catch((error) => - setNotif({ - message: i18n.str`Failed to update instance`, - type: "ERROR", - description: error instanceof Error ? error.message : String(error), - }), - ); - }} + onUpdate={async (d) => doUpdateImpl(d, undefined)} /> + <div class="columns"> + <div class="column" /> + <div class="column is-four-fifths"> + <button + class="button " + onClick={() => { + setDeleting(true); + }} + > + <i18n.Translate>Delete this instance</i18n.Translate> + </button> + + {!deleting ? undefined : ( + <DeletePage instanceId={state.instance} onDeleted={() => {}} /> + )} + </div> + <div class="column" /> + </div> </Fragment> ); } diff --git a/packages/merchant-backoffice-ui/src/paths/login/index.tsx b/packages/merchant-backoffice-ui/src/paths/login/index.tsx @@ -26,7 +26,7 @@ import { HttpStatusCode, LoginTokenRequest, LoginTokenScope, - TranslatedString + TranslatedString, } from "@gnu-taler/taler-util"; import { useTranslationContext } from "@gnu-taler/web-util/browser"; import { Fragment, h, VNode } from "preact"; @@ -35,9 +35,7 @@ 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 { - usePreference -} from "../../hooks/preference.js"; +import { usePreference } from "../../hooks/preference.js"; import { Notification } from "../../utils/types.js"; interface Props {} @@ -67,63 +65,70 @@ export function LoginPage(_p: Props): VNode { ChallengeResponse | undefined >(); - const { i18n } = useTranslationContext(); 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; - logIn(username, token); - return; - } else { - switch (result.case) { - case HttpStatusCode.Unauthorized: { - setNotif({ - message: i18n.str`Your password is incorrect`, - type: "ERROR", - }); - return; - } - case HttpStatusCode.NotFound: { - setNotif({ - message: i18n.str`Your instance cannot be found`, - type: "ERROR", - }); - return; - } - case HttpStatusCode.Accepted: { - setCurrentChallenge(result.body) - return; - } - default: { - assertUnreachable(result) + try { + 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; + logIn(username, token); + return; + } else { + switch (result.case) { + case HttpStatusCode.Unauthorized: { + setNotif({ + message: i18n.str`Your password is incorrect`, + type: "ERROR", + }); + return; + } + case HttpStatusCode.NotFound: { + setNotif({ + message: i18n.str`Your instance cannot be found`, + type: "ERROR", + }); + return; + } + case HttpStatusCode.Accepted: { + setCurrentChallenge(result.body); + return; + } + default: { + assertUnreachable(result); + } } } + } catch (error) { + setNotif({ + message: i18n.str`Failed to login.`, + type: "ERROR", + description: error instanceof Error ? error.message : undefined, + }); } } - if (currentChallenge) { - return ( - <SolveMFAChallenges - currentChallenge={currentChallenge} - onCompleted={doLoginImpl} - onCancel={() => { - setCurrentChallenge(undefined); - }} - /> - ); - } - + if (currentChallenge) { + return ( + <SolveMFAChallenges + currentChallenge={currentChallenge} + onCompleted={doLoginImpl} + onCancel={() => { + setCurrentChallenge(undefined); + }} + /> + ); + } + return ( <Fragment> <NotificationCard notification={notif} /> @@ -210,12 +215,19 @@ export function LoginPage(_p: Props): VNode { {!config.have_self_provisioning ? ( <div /> ) : ( - <a href={`#/account/reset/${username}`} class="button " disabled={!username}> + <a + href={ + !username || username === "admin" + ? undefined + : `#/account/reset/${username}` + } + class="button " + disabled={!username || username === "admin"} + > <i18n.Translate>Forgot password</i18n.Translate> </a> )} <AsyncButton - type="is-info" disabled={!username || !password} onClick={() => doLoginImpl(undefined)} > diff --git a/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx b/packages/merchant-backoffice-ui/src/paths/resetAccount/index.tsx @@ -53,8 +53,8 @@ export function ResetAccount({ const { state: session, lib, logIn } = useSessionContext(); const [notif, setNotif] = useState<Notification | undefined>(undefined); const [value, setValue] = useState<Partial<Form>>({ - password: "asd", - repeat: "asd", + // password: "asd", + // repeat: "asd", }); const [currentChallenge, setCurrentChallenge] = useState< ChallengeResponse | undefined @@ -80,7 +80,7 @@ export function ResetAccount({ async function doResetImpl(challengeIds: string[] | undefined) { try { - const resp = await lib.instance.forgotPasswordSelfProvision( + const resp = await lib.subInstanceApi(instanceId).instance.forgotPasswordSelfProvision( { method: MerchantAuthMethod.TOKEN, password: value.password!, @@ -184,7 +184,6 @@ export function ResetAccount({ <i18n.Translate>Cancel</i18n.Translate> </button> <AsyncButton - type="is-info" disabled={!errors} onClick={() => doResetImpl(undefined)} > diff --git a/packages/taler-util/src/http-client/merchant.ts b/packages/taler-util/src/http-client/merchant.ts @@ -639,6 +639,7 @@ export class TalerMerchantInstanceHttpClient { async updateCurrentInstance( token: AccessToken | undefined, body: TalerMerchantApi.InstanceReconfigurationMessage, + params: { challengeIds?: string[] } = {}, ) { const url = new URL(`private`, this.baseUrl); @@ -646,6 +647,10 @@ export class TalerMerchantInstanceHttpClient { 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: "PATCH", body, @@ -658,6 +663,13 @@ export class TalerMerchantInstanceHttpClient { ); return opEmptySuccess(); } + case HttpStatusCode.Accepted: { + return opKnownAlternativeHttpFailure( + resp, + resp.status, + codecForChallengeResponse(), + ); + } case HttpStatusCode.Unauthorized: // FIXME: missing in docs return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.NotFound: @@ -2661,6 +2673,8 @@ export class TalerMerchantInstanceHttpClient { codecForChallengeResponse(), ); } + case HttpStatusCode.NotFound: + return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Forbidden: return opKnownHttpFailure(resp.status, resp); case HttpStatusCode.Unauthorized: