commit dc281d20a65053ae4b94fb2c2605ba27fc41cf5d parent d09f4d987618fcd19f38ddc6c90513ff46968c37 Author: Florian Dold <dold@taler.net> Date: Fri, 4 Sep 2026 16:58:19 +0200 merchant web UI: add self-service account deletion Diffstat:
10 files changed, 767 insertions(+), 5 deletions(-)
diff --git a/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx b/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx @@ -27,7 +27,9 @@ import { session, currentBackendUrl, currentAccount, + signOut, } from "../stores/session.js"; +import { useLocation } from "wouter-preact"; import { useTranslation } from "../context/translation.js"; import { BusinessSettingsScreen } from "../screens/BusinessSettingsScreen.js"; import type { PendingProtectedAction } from "../api/protectedAction.js"; @@ -46,6 +48,7 @@ export function BusinessSettingsRoute({ onMfaRequired, }: BusinessSettingsRouteProps): VNode { const { t } = useTranslation(); + const [, setLocation] = useLocation(); const { settings, updateSettings, resource } = useBusinessSettings(); const { phoneRegex } = useMerchantConfig(); const hasToken = Boolean(session.value.token); @@ -56,12 +59,71 @@ export function BusinessSettingsRoute({ ? configurationFailure(t`Your current password is not correct.`) : normalized; }; + const accountId = currentAccount.value; + + const deleteCurrentAccount = async ( + purge: boolean, + challengeIds?: string[], + ): Promise<TalerMerchantApi.ChallengeResponse | undefined> => { + const token = session.value.token; + if (!token) throw configurationFailure(t`Not authenticated.`); + const client = merchantClient({ + rootUrl: new URL(currentBackendUrl.value), + account: currentAccount.value, + }); + const result = await client.deleteCurrentInstance(token, { + purge, + challengeIds, + }); + if (result.type === "fail" && result.case === HttpStatusCode.Accepted) { + return result.body as TalerMerchantApi.ChallengeResponse; + } + unwrapEmpty(result); + return undefined; + }; return ( <BusinessSettingsScreen settings={hasToken ? settings : undefined} settingsResource={resource} phoneRegex={phoneRegex} + accountId={hasToken && accountId !== "admin" ? accountId : undefined} + onDeleteAccount={ + hasToken && accountId !== "admin" + ? async (mode) => { + const purge = mode === "purge"; + const challengeResponse = await deleteCurrentAccount(purge); + if (challengeResponse) { + onMfaRequired({ + challengeAccount: accountId, + challengeResponse, + actionNotice: purge + ? t`Permanently purging merchant account ${accountId}` + : t`Disabling merchant account ${accountId}`, + cancelTo: "/settings/account", + continueWith: (challengeIds) => + runProtectedMutation(async () => { + const continued = await deleteCurrentAccount( + purge, + challengeIds, + ); + if (continued) { + return { + type: "challenge" as const, + challenge: continued, + }; + } + signOut(); + return { redirectTo: "/signin" }; + }), + }); + return; + } + signOut(); + setLocation("/signin"); + } + : undefined + } onSave={async (newSettings) => { if (hasToken && updateSettings) { const res = await updateSettings(newSettings); diff --git a/packages/taler-merchant-webui/src/routes/businessSettings.test.tsx b/packages/taler-merchant-webui/src/routes/businessSettings.test.tsx @@ -0,0 +1,314 @@ +/* + This file is part of GNU Taler + (C) 2026 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. + */ + +import assert from "node:assert"; +import test from "node:test"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { Router } from "wouter-preact"; +import { mutate } from "swr"; +import { BusinessSettingsRoute } from "./BusinessSettingsRoute.js"; +import { useHttpLibForTesting } from "../api/client.js"; +import { conflict, FakeHttpLib, noContent, ok } from "../testing/fake-http.js"; +import { session, signIn, signOut } from "../stores/session.js"; +import type { PendingProtectedAction } from "../api/protectedAction.js"; + +const config = { + version: "26:0:0", + name: "taler-merchant", + currency: "EUR", + currencies: {}, + exchanges: [], + default_persona: "expert", + report_generators: [], + have_donau: false, + mandatory_tan_channels: [], + payment_target_types: "*", +}; + +const settings = { + name: "ACME Coffee", + user_type: "business", + merchant_pub: "M".repeat(52), + address: {}, + jurisdiction: {}, + use_stefan: false, + default_wire_transfer_delay: { d_us: 1000 }, + default_pay_delay: { d_us: 1000 }, + default_refund_delay: { d_us: 1000 }, + accounts: [], + auth: { method: "token" }, +}; + +function button( + container: HTMLElement, + label: string, +): HTMLButtonElement | undefined { + return Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes(label), + ); +} + +async function waitForButton( + container: HTMLElement, + label: string, +): Promise<HTMLButtonElement> { + for (let attempt = 0; attempt < 20; attempt += 1) { + const found = button(container, label); + if (found) return found; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + throw new Error( + `Button "${label}" did not render. Screen: ${container.textContent}`, + ); +} + +function routeHttp( + deleteHandler: Parameters<FakeHttpLib["handle"]>[0], +): FakeHttpLib { + return new FakeHttpLib().handle((request) => { + if (request.method === "GET" && request.url.endsWith("/config")) { + return ok(config); + } + if ( + request.method === "GET" && + new URL(request.url).pathname.endsWith("/instances/cafe/private") + ) { + return ok(settings); + } + return deleteHandler(request); + }); +} + +async function seedBusinessSettings(backendUrl: string, account: string) { + await mutate( + [ + "getCurrentInstanceDetails", + new URL(backendUrl).href, + account, + session.value.token, + ], + { name: "ACME Coffee" }, + false, + ); +} + +test("deleting the current account signs out and redirects to sign-in", async () => { + const backendUrl = "https://merchant-delete.example.com/"; + let location = "/settings/account"; + const locationHook = () => + [location, (next: string) => (location = next)] as const; + const http = routeHttp((request) => + request.method === "DELETE" ? noContent() : undefined, + ); + const restoreHttp = useHttpLibForTesting(http); + signIn("cafe", "secret-token:portal", backendUrl); + await seedBusinessSettings(backendUrl, "cafe"); + const container = document.createElement("div"); + document.body.appendChild(container); + + try { + render( + <Router hook={locationHook as never}> + <BusinessSettingsRoute + onMfaRequired={() => { + throw new Error("MFA was not expected"); + }} + /> + </Router>, + container, + ); + const disable = await waitForButton(container, "Disable account"); + act(() => disable.click()); + const dialog = document.body.querySelector( + '[role="dialog"]', + ) as HTMLElement; + await act(async () => { + button(dialog, "Disable account")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const request = http.requests.find( + (candidate) => candidate.method === "DELETE", + ); + assert.ok(request); + assert.strictEqual( + request.url, + "https://merchant-delete.example.com/instances/cafe/private?purge=NO", + ); + assert.strictEqual( + request.headers?.Authorization, + "Bearer secret-token:portal", + ); + assert.strictEqual(session.value.token, undefined); + assert.strictEqual(location, "/signin"); + } finally { + render(null, container); + document.body.removeChild(container); + restoreHttp(); + signOut(); + } +}); + +test("MFA-completed purge sends challenge IDs before signing out", async () => { + const backendUrl = "https://merchant-mfa.example.com/"; + let pending: PendingProtectedAction | null = null; + const challenge = { + combi_and: false, + challenges: [ + { + challenge_id: "CH-1", + tan_channel: "email", + tan_info: "o***@example.com", + }, + ], + }; + const http = routeHttp((request) => { + if (request.method !== "DELETE") return undefined; + return request.headers?.["Taler-Challenge-Ids"] === "CH-1" + ? noContent() + : { status: 202, body: challenge }; + }); + const restoreHttp = useHttpLibForTesting(http); + signIn("cafe", "secret-token:portal", backendUrl); + await seedBusinessSettings(backendUrl, "cafe"); + const container = document.createElement("div"); + document.body.appendChild(container); + const locationHook = () => ["/settings/account", () => undefined] as const; + + try { + render( + <Router hook={locationHook as never}> + <BusinessSettingsRoute onMfaRequired={(action) => (pending = action)} /> + </Router>, + container, + ); + const purgeButton = await waitForButton(container, "Purge account"); + act(() => purgeButton.click()); + const dialog = document.body.querySelector( + '[role="dialog"]', + ) as HTMLElement; + const confirmation = dialog.querySelector( + "#purge-current-account-id", + ) as HTMLInputElement; + act(() => { + confirmation.value = "cafe"; + confirmation.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + button(dialog, "Purge permanently")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + const action = pending as PendingProtectedAction | null; + assert.ok(action); + assert.strictEqual(session.value.account, "cafe"); + assert.strictEqual(action.cancelTo, "/settings/account"); + assert.strictEqual(action.challengeAccount, "cafe"); + const result = await action.continueWith(["CH-1"]); + assert.strictEqual(result.type, "success"); + if (result.type === "success") { + assert.strictEqual(result.value.redirectTo, "/signin"); + } + const requests = http.requests.filter( + (candidate) => candidate.method === "DELETE", + ); + assert.strictEqual(requests.length, 2); + assert.strictEqual( + requests[0]?.url, + "https://merchant-mfa.example.com/instances/cafe/private?purge=YES", + ); + assert.strictEqual(requests[1]?.headers?.["Taler-Challenge-Ids"], "CH-1"); + assert.strictEqual(session.value.token, undefined); + } finally { + render(null, container); + document.body.removeChild(container); + restoreHttp(); + signOut(); + } +}); + +test("a rejected deletion keeps the current session and confirmation open", async () => { + const backendUrl = "https://merchant-conflict.example.com/"; + const http = routeHttp((request) => + request.method === "DELETE" + ? conflict({ code: 2000, hint: "account is still in use" }) + : undefined, + ); + const restoreHttp = useHttpLibForTesting(http); + signIn("cafe", "secret-token:portal", backendUrl); + await seedBusinessSettings(backendUrl, "cafe"); + const container = document.createElement("div"); + document.body.appendChild(container); + const locationHook = () => ["/settings/account", () => undefined] as const; + + try { + render( + <Router hook={locationHook as never}> + <BusinessSettingsRoute onMfaRequired={() => undefined} /> + </Router>, + container, + ); + const disable = await waitForButton(container, "Disable account"); + act(() => disable.click()); + const dialog = document.body.querySelector( + '[role="dialog"]', + ) as HTMLElement; + await act(async () => { + button(dialog, "Disable account")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + assert.strictEqual(session.value.token, "secret-token:portal"); + assert.ok(document.body.querySelector('[role="dialog"]')); + assert.match(dialog.textContent ?? "", /account is still in use/); + } finally { + render(null, container); + document.body.removeChild(container); + restoreHttp(); + signOut(); + } +}); + +test("the admin account cannot delete itself from business settings", async () => { + const backendUrl = "https://merchant-admin.example.com/"; + const http = new FakeHttpLib() + .on("GET", "/config", ok(config)) + .on("GET", "/private", ok(settings)); + const restoreHttp = useHttpLibForTesting(http); + signIn("admin", "secret-token:portal", backendUrl); + await seedBusinessSettings(backendUrl, "admin"); + const container = document.createElement("div"); + document.body.appendChild(container); + const locationHook = () => ["/settings/account", () => undefined] as const; + + try { + render( + <Router hook={locationHook as never}> + <BusinessSettingsRoute onMfaRequired={() => undefined} /> + </Router>, + container, + ); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.doesNotMatch(container.textContent ?? "", /Danger zone/); + assert.equal( + http.requests.some((candidate) => candidate.method === "DELETE"), + false, + ); + } finally { + render(null, container); + document.body.removeChild(container); + restoreHttp(); + signOut(); + } +}); diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx @@ -20,6 +20,7 @@ import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; import { InitialLoadingState } from "../ui/InitialLoadingState.js"; import { PasswordInput } from "../ui/PasswordInput.js"; +import { Modal } from "../ui/Modal.js"; import { useImageDataUrlStatus } from "../ui/imageDataUrl.js"; import { useTranslation, type TranslateFn } from "../context/translation.js"; import type { RemoteResource } from "../api/contracts.js"; @@ -32,6 +33,7 @@ export type PasswordChangeResult = | { status: "changed" } | { status: "challenge" } | { status: "error"; error: unknown }; +export type AccountDeletionMode = "disable" | "purge"; type EditorSection = | "profile" @@ -51,6 +53,8 @@ export interface BusinessSettingsScreenProps { currentPassword: string; newPassword: string; }) => Promise<PasswordChangeResult>; + accountId?: string; + onDeleteAccount?: (mode: AccountDeletionMode) => Promise<void>; /** Open the relevant editor in focused tutorial previews. */ initialSection?: Exclude<EditorSection, "phone">; } @@ -243,6 +247,8 @@ export function BusinessSettingsScreen({ phoneRegex, onSave, onChangePassword, + accountId, + onDeleteAccount, initialSection, }: BusinessSettingsScreenProps): VNode { const { t } = useTranslation(); @@ -296,6 +302,13 @@ export function BusinessSettingsScreen({ const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); const [confirmPassword, setConfirmPassword] = useState(""); + const [deletionMode, setDeletionMode] = useState<AccountDeletionMode | null>( + null, + ); + const [deletionAccountId, setDeletionAccountId] = useState(""); + const [deletionPending, setDeletionPending] = useState(false); + const [deletionError, setDeletionError] = useState<unknown>(); + const deletionLock = useRef(false); const committedLogoStatus = useImageDataUrlStatus(committed.logoUrl || ""); const draftLogoStatus = useImageDataUrlStatus(logoUrl); @@ -433,6 +446,37 @@ export function BusinessSettingsScreen({ } }; + const openDeletion = (mode: AccountDeletionMode) => { + setDeletionMode(mode); + setDeletionAccountId(""); + setDeletionError(undefined); + }; + + const closeDeletion = () => { + if (deletionPending) return; + setDeletionMode(null); + setDeletionAccountId(""); + setDeletionError(undefined); + }; + + const deleteAccount = async () => { + if (!deletionMode || !onDeleteAccount || deletionLock.current) return; + if (deletionMode === "purge" && deletionAccountId !== accountId) return; + deletionLock.current = true; + setDeletionPending(true); + setDeletionError(undefined); + try { + await onDeleteAccount(deletionMode); + setDeletionMode(null); + setDeletionAccountId(""); + } catch (error) { + setDeletionError(error); + } finally { + deletionLock.current = false; + setDeletionPending(false); + } + }; + const pageHeader = ( <Header title={t`Merchant account`} @@ -951,6 +995,107 @@ export function BusinessSettingsScreen({ </form> </DisclosureSection> </div> + + {accountId && onDeleteAccount && ( + <div class="space-y-3"> + <div class="px-1"> + <h2 class="text-lg font-bold text-red-800">{t`Danger zone`}</h2> + <p class="mt-1 text-sm text-gray-600">{t`Disable this merchant account or permanently delete all of its data.`}</p> + </div> + + <section class="divide-y divide-red-100 overflow-hidden rounded-xl border border-red-200 bg-white shadow-2xs"> + <div class="flex flex-col items-start justify-between gap-4 p-4 sm:flex-row sm:items-center sm:p-5"> + <div> + <h3 class="text-sm font-bold text-gray-900">{t`Disable merchant account`}</h3> + <p class="mt-1 max-w-2xl text-xs leading-relaxed text-gray-600">{t`Delete the account’s private key and prevent new orders and payments while retaining transaction records.`}</p> + </div> + <Button + variant="danger" + class="shrink-0" + onClick={() => openDeletion("disable")} + >{t`Disable account`}</Button> + </div> + <div class="flex flex-col items-start justify-between gap-4 p-4 sm:flex-row sm:items-center sm:p-5"> + <div> + <h3 class="text-sm font-bold text-gray-900">{t`Permanently purge merchant account`}</h3> + <p class="mt-1 max-w-2xl text-xs leading-relaxed text-gray-600">{t`Permanently delete this account and its transaction data. This cannot be undone.`}</p> + </div> + <Button + variant="danger" + class="shrink-0" + onClick={() => openDeletion("purge")} + >{t`Purge account`}</Button> + </div> + </section> + </div> + )} + + <Modal + isOpen={deletionMode !== null} + onClose={closeDeletion} + title={ + deletionMode === "purge" + ? t`Permanently purge merchant account` + : t`Disable merchant account` + } + > + <div class="space-y-4"> + <ApiErrorBanner + error={deletionError} + title={ + deletionMode === "purge" + ? t`Purge failed` + : t`Account could not be disabled` + } + /> + {deletionMode === "purge" ? ( + <> + <p class="text-sm leading-relaxed text-gray-700">{t`Purging permanently deletes this merchant account and all of its transaction data. You will be signed out immediately.`}</p> + <div> + <label + htmlFor="purge-current-account-id" + class="block text-xs font-bold text-gray-700" + > + {t`Type the account ID to confirm`}:{" "} + <span class="font-mono">{accountId}</span> + </label> + <input + id="purge-current-account-id" + value={deletionAccountId} + onInput={(event) => + setDeletionAccountId(event.currentTarget.value) + } + disabled={deletionPending} + autoComplete="off" + class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm focus:outline-none focus:ring-2 focus:ring-red-500" + /> + </div> + </> + ) : ( + <p class="text-sm leading-relaxed text-gray-700">{t`Disabling deletes this merchant account’s private key and prevents new orders and payments. Transaction records are retained, and you will be signed out immediately.`}</p> + )} + <div class="flex justify-end gap-3"> + <Button + variant="secondary" + onClick={closeDeletion} + disabled={deletionPending} + >{t`Cancel`}</Button> + <Button + variant="danger" + onClick={() => void deleteAccount()} + isLoading={deletionPending} + disabled={ + deletionPending || + (deletionMode === "purge" && deletionAccountId !== accountId) + } + > + {deletionMode === "purge" + ? t`Purge permanently` + : t`Disable account`} + </Button> + </div> + </div> + </Modal> </div> ); } diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx @@ -1828,6 +1828,102 @@ test("BusinessSettingsScreen keeps the password editor open while waiting for MF document.body.removeChild(container); }); +test("BusinessSettingsScreen confirms disable and requires the exact account ID for purge", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const operations: string[] = []; + render( + <BusinessSettingsScreen + settings={{ name: "ACME Coffee" }} + accountId="acme-coffee" + onDeleteAccount={async (mode) => { + operations.push(mode); + }} + />, + container, + ); + + assert.match(container.textContent ?? "", /Danger zone/); + act(() => findButton(container, "Disable account")?.click()); + let dialog = document.body.querySelector('[role="dialog"]') as HTMLElement; + assert.ok(dialog); + await act(async () => { + findButton(dialog, "Disable account")?.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.deepStrictEqual(operations, ["disable"]); + assert.equal(document.body.querySelector('[role="dialog"]'), null); + + act(() => findButton(container, "Purge account")?.click()); + dialog = document.body.querySelector('[role="dialog"]') as HTMLElement; + assert.ok(dialog); + const purge = findButton(dialog, "Purge permanently") as HTMLButtonElement; + const confirmation = dialog.querySelector( + "#purge-current-account-id", + ) as HTMLInputElement; + assert.ok(purge.disabled); + + act(() => { + confirmation.value = "ACME Coffee"; + confirmation.dispatchEvent(new Event("input", { bubbles: true })); + }); + assert.ok(purge.disabled); + act(() => { + confirmation.value = "acme-coffee"; + confirmation.dispatchEvent(new Event("input", { bubbles: true })); + }); + assert.equal(purge.disabled, false); + await act(async () => { + purge.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.deepStrictEqual(operations, ["disable", "purge"]); + + render(null, container); + document.body.removeChild(container); +}); + +test("BusinessSettingsScreen keeps a failed deletion open and blocks duplicate submissions", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + let attempts = 0; + let finish: (() => void) | undefined; + render( + <BusinessSettingsScreen + settings={{ name: "ACME Coffee" }} + accountId="acme-coffee" + onDeleteAccount={() => { + attempts += 1; + return new Promise<void>((resolve, reject) => { + finish = () => reject(new Error("pending offers prevent deletion")); + }); + }} + />, + container, + ); + + act(() => findButton(container, "Disable account")?.click()); + const dialog = document.body.querySelector('[role="dialog"]') as HTMLElement; + const confirm = findButton(dialog, "Disable account") as HTMLButtonElement; + await act(async () => { + confirm.click(); + confirm.click(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.strictEqual(attempts, 1); + assert.ok(confirm.disabled); + + await act(async () => { + finish?.(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.match(dialog.textContent ?? "", /pending offers prevent deletion/); + assert.equal(confirm.disabled, false); + + render(null, container); + document.body.removeChild(container); +}); + test("BusinessSettingsScreen distinguishes initial loading, initial failure, and stale refresh failure", async () => { const container = document.createElement("div"); document.body.appendChild(container); diff --git a/packages/taler-merchant-webui/src/stories/story-data.tsx b/packages/taler-merchant-webui/src/stories/story-data.tsx @@ -1004,6 +1004,8 @@ export const STORIES: Story[] = [ "Grouped business profile, order defaults, and account security settings.", render: () => ( <BusinessSettingsScreen + accountId="alpenblick-coffee" + onDeleteAccount={async () => undefined} settings={{ name: "Alpenblick Coffee", email: "hello@alpenblick.example", diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.aria.yml @@ -23,6 +23,10 @@ - role: "paragraph" - role: "generic" - role: "generic" + - role: "heading" + name: "Danger zone" + - role: "paragraph" + - role: "generic" - role: "button" name: "Setup" - role: "generic" @@ -64,6 +68,20 @@ - role: "button" name: "Account password Password is hidden Edit" state: "expanded=false" + - role: "StaticText" + name: "Danger zone" + - role: "StaticText" + name: "Disable this merchant account or permanently delete all of its data." + - role: "heading" + name: "Disable merchant account" + - role: "paragraph" + - role: "button" + name: "Disable account" + - role: "heading" + name: "Permanently purge merchant account" + - role: "paragraph" + - role: "button" + name: "Purge account" - role: "StaticText" name: "Setup" - role: "StaticText" @@ -121,6 +139,22 @@ - role: "generic" - role: "StaticText" name: "Edit" + - role: "InlineTextBox" + name: "Danger zone" + - role: "InlineTextBox" + name: "Disable this merchant account or permanently delete all of its data." + - role: "StaticText" + name: "Disable merchant account" + - role: "StaticText" + name: "Delete the account’s private key and prevent new orders and payments while retaining transaction records." + - role: "StaticText" + name: "Disable account" + - role: "StaticText" + name: "Permanently purge merchant account" + - role: "StaticText" + name: "Permanently delete this account and its transaction data. This cannot be undone." + - role: "StaticText" + name: "Purge account" - role: "InlineTextBox" name: "Setup" - role: "InlineTextBox" @@ -193,6 +227,18 @@ name: "Password is hidden" - role: "InlineTextBox" name: "Edit" + - role: "InlineTextBox" + name: "Disable merchant account" + - role: "InlineTextBox" + name: "Delete the account’s private key and prevent new orders and payments while retaining transaction records." + - role: "InlineTextBox" + name: "Disable account" + - role: "InlineTextBox" + name: "Permanently purge merchant account" + - role: "InlineTextBox" + name: "Permanently delete this account and its transaction data. This cannot be undone." + - role: "InlineTextBox" + name: "Purge account" - role: "InlineTextBox" name: "Identity and logo" - role: "StaticText" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.webp b/packages/taler-merchant-webui/visual/baselines/merchant-account-desktop.webp Binary files differ. diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-mobile.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-mobile.aria.yml @@ -23,6 +23,10 @@ - role: "paragraph" - role: "generic" - role: "generic" + - role: "heading" + name: "Danger zone" + - role: "paragraph" + - role: "generic" - role: "button" name: "Setup" - role: "generic" @@ -67,6 +71,20 @@ - role: "button" name: "Account password Password is hidden Edit" state: "expanded=false" + - role: "StaticText" + name: "Danger zone" + - role: "StaticText" + name: "Disable this merchant account or permanently delete all of its data." + - role: "heading" + name: "Disable merchant account" + - role: "paragraph" + - role: "button" + name: "Disable account" + - role: "heading" + name: "Permanently purge merchant account" + - role: "paragraph" + - role: "button" + name: "Purge account" - role: "StaticText" name: "Setup" - role: "StaticText" @@ -148,6 +166,24 @@ - role: "generic" - role: "StaticText" name: "Edit" + - role: "InlineTextBox" + name: "Danger zone" + - role: "InlineTextBox" + name: "Disable this merchant account or " + - role: "InlineTextBox" + name: "permanently delete all of its data." + - role: "StaticText" + name: "Disable merchant account" + - role: "StaticText" + name: "Delete the account’s private key and prevent new orders and payments while retaining transaction records." + - role: "StaticText" + name: "Disable account" + - role: "StaticText" + name: "Permanently purge merchant account" + - role: "StaticText" + name: "Permanently delete this account and its transaction data. This cannot be undone." + - role: "StaticText" + name: "Purge account" - role: "InlineTextBox" name: "Setup" - role: "InlineTextBox" @@ -244,6 +280,26 @@ name: "Password is hidden" - role: "InlineTextBox" name: "Edit" + - role: "InlineTextBox" + name: "Disable merchant account" + - role: "InlineTextBox" + name: "Delete the account’s private key and prevent " + - role: "InlineTextBox" + name: "new orders and payments while retaining " + - role: "InlineTextBox" + name: "transaction records." + - role: "InlineTextBox" + name: "Disable account" + - role: "InlineTextBox" + name: "Permanently purge merchant " + - role: "InlineTextBox" + name: "account" + - role: "InlineTextBox" + name: "Permanently delete this account and its " + - role: "InlineTextBox" + name: "transaction data. This cannot be undone." + - role: "InlineTextBox" + name: "Purge account" - role: "InlineTextBox" name: "Business Name" - role: "InlineTextBox" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.aria.yml b/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.aria.yml @@ -23,6 +23,10 @@ - role: "paragraph" - role: "generic" - role: "generic" + - role: "heading" + name: "Danger zone" + - role: "paragraph" + - role: "generic" - role: "button" name: "Setup" - role: "generic" @@ -67,6 +71,20 @@ - role: "StaticText" name: "Editing" - role: "form" + - role: "StaticText" + name: "Danger zone" + - role: "StaticText" + name: "Disable this merchant account or permanently delete all of its data." + - role: "heading" + name: "Disable merchant account" + - role: "paragraph" + - role: "button" + name: "Disable account" + - role: "heading" + name: "Permanently purge merchant account" + - role: "paragraph" + - role: "button" + name: "Purge account" - role: "StaticText" name: "Setup" - role: "StaticText" @@ -132,7 +150,6 @@ - role: "button" name: "Show password" state: "pressed=false" - - role: "paragraph" - role: "LabelText" - role: "textbox" name: "New Password *" @@ -151,6 +168,22 @@ name: "Cancel" - role: "button" name: "Update password" + - role: "InlineTextBox" + name: "Danger zone" + - role: "InlineTextBox" + name: "Disable this merchant account or permanently delete all of its data." + - role: "StaticText" + name: "Disable merchant account" + - role: "StaticText" + name: "Delete the account’s private key and prevent new orders and payments while retaining transaction records." + - role: "StaticText" + name: "Disable account" + - role: "StaticText" + name: "Permanently purge merchant account" + - role: "StaticText" + name: "Permanently delete this account and its transaction data. This cannot be undone." + - role: "StaticText" + name: "Purge account" - role: "InlineTextBox" name: "Setup" - role: "InlineTextBox" @@ -225,8 +258,6 @@ name: "Current Password" - role: "generic" - role: "StaticText" - name: "Confirmed locally in this browser before the change is sent to the server." - - role: "StaticText" name: "New Password" - role: "generic" - role: "StaticText" @@ -236,6 +267,18 @@ name: "Cancel" - role: "StaticText" name: "Update password" + - role: "InlineTextBox" + name: "Disable merchant account" + - role: "InlineTextBox" + name: "Delete the account’s private key and prevent new orders and payments while retaining transaction records." + - role: "InlineTextBox" + name: "Disable account" + - role: "InlineTextBox" + name: "Permanently purge merchant account" + - role: "InlineTextBox" + name: "Permanently delete this account and its transaction data. This cannot be undone." + - role: "InlineTextBox" + name: "Purge account" - role: "InlineTextBox" name: "Identity and logo" - role: "StaticText" @@ -293,8 +336,6 @@ - role: "InlineTextBox" name: "Current Password" - role: "InlineTextBox" - name: "Confirmed locally in this browser before the change is sent to the server." - - role: "InlineTextBox" name: "New Password" - role: "InlineTextBox" name: "Confirm New Password" diff --git a/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.webp b/packages/taler-merchant-webui/visual/baselines/merchant-account-password-desktop.webp Binary files differ.