commit 74cbf5e77a517e9e0a6d577f10ee54f8760f0436
parent 78c6b7a6f792f9d1eb1544dc15949283e7852e3c
Author: Sebastian <sebasjm@gmail.com>
Date: Mon, 15 Sep 2025 11:42:25 -0300
fixes #10373
Diffstat:
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: