commit f69b6208c6d276425d1a73b77aa4db027a1088a7
parent 577ebe51348eaae035a5928520eabc59e34a2693
Author: Florian Dold <dold@taler.net>
Date: Wed, 26 Aug 2026 00:54:53 +0200
merchant web UI: validate MFA phone numbers
Diffstat:
12 files changed, 3335 insertions(+), 1107 deletions(-)
diff --git a/packages/taler-merchant-webui/src/api/hooks/useMerchantConfig.test.ts b/packages/taler-merchant-webui/src/api/hooks/useMerchantConfig.test.ts
@@ -0,0 +1,32 @@
+/*
+ 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 { TalerMerchantApi } from "@gnu-taler/taler-util";
+import { mapMerchantConfig } from "./useMerchantConfig.js";
+
+test("merchant config preserves the backend phone policy", () => {
+ const mapped = mapMerchantConfig({
+ version: "26:0:0",
+ name: "taler-merchant",
+ currency: "CHF",
+ default_persona: "expert",
+ currencies: {},
+ report_generators: [],
+ phone_regex: "^\\+41[0-9]+$",
+ exchanges: [],
+ have_self_provisioning: true,
+ have_donau: false,
+ mandatory_tan_channels: [TalerMerchantApi.TanChannel.SMS],
+ payment_target_types: "*",
+ });
+
+ assert.strictEqual(mapped.phoneRegex, "^\\+41[0-9]+$");
+});
diff --git a/packages/taler-merchant-webui/src/api/hooks/useMerchantConfig.ts b/packages/taler-merchant-webui/src/api/hooks/useMerchantConfig.ts
@@ -19,34 +19,39 @@ import { merchantManagementClient } from "../client.js";
import { unwrap } from "../failure.js";
import { getClientConfig } from "./common.js";
import { remoteResource } from "../contracts.js";
+import type { TalerMerchantApi } from "@gnu-taler/taler-util";
+
+export function mapMerchantConfig(
+ body: TalerMerchantApi.MerchantVersionResponse,
+) {
+ return {
+ currency: body.currency,
+ currencies: body.currencies ?? {},
+ version: body.version,
+ name: body.name || "taler-merchant-httpd",
+ exchanges: Array.isArray(body.exchanges) ? body.exchanges : [],
+ reportGenerators: Object.fromEntries(
+ (body.report_generators ?? []).map((section) => [section, section]),
+ ),
+ haveSelfProvisioning: body.have_self_provisioning === true,
+ mandatoryTanChannels: body.mandatory_tan_channels ?? [],
+ phoneRegex: body.phone_regex,
+ paymentTargetTypes: body.payment_target_types,
+ paymentTargetRegex: body.payment_target_regex,
+ };
+}
/**
* Fetch server configuration (/config) and primary currency.
*/
export function useMerchantConfig() {
const config = getClientConfig();
- const swr = useSWR(
- ["getConfig", config.rootUrl.href],
- async () => {
- const client = merchantManagementClient(config);
- const res = await client.getConfig();
- const body = unwrap(res);
- return {
- currency: body?.currency,
- currencies: body?.currencies ?? {},
- version: body?.version,
- name: body?.name || "taler-merchant-httpd",
- exchanges: Array.isArray(body?.exchanges) ? body.exchanges : [],
- reportGenerators: Object.fromEntries(
- (body?.report_generators ?? []).map((section) => [section, section]),
- ),
- haveSelfProvisioning: body?.have_self_provisioning === true,
- mandatoryTanChannels: body?.mandatory_tan_channels ?? [],
- paymentTargetTypes: body?.payment_target_types,
- paymentTargetRegex: body?.payment_target_regex,
- };
- },
- );
+ const swr = useSWR(["getConfig", config.rootUrl.href], async () => {
+ const client = merchantManagementClient(config);
+ const res = await client.getConfig();
+ const body = unwrap(res);
+ return mapMerchantConfig(body);
+ });
const { data, error, isLoading, isValidating, mutate } = swr;
return {
@@ -56,6 +61,7 @@ export function useMerchantConfig() {
reportGenerators: data?.reportGenerators,
haveSelfProvisioning: data?.haveSelfProvisioning,
mandatoryTanChannels: data?.mandatoryTanChannels,
+ phoneRegex: data?.phoneRegex,
paymentTargetTypes: data?.paymentTargetTypes,
paymentTargetRegex: data?.paymentTargetRegex,
config: data,
diff --git a/packages/taler-merchant-webui/src/routes/AdminAccountsRoutes.tsx b/packages/taler-merchant-webui/src/routes/AdminAccountsRoutes.tsx
@@ -8,16 +8,14 @@
*/
import type { VNode } from "preact";
-import {
- MerchantAuthMethod,
- TalerMerchantApi,
-} from "@gnu-taler/taler-util";
+import { MerchantAuthMethod, TalerMerchantApi } from "@gnu-taler/taler-util";
import { useLocation } from "wouter-preact";
import { useMemo } from "preact/hooks";
import {
useManagedInstance,
useManagedInstanceKyc,
useManagedInstances,
+ useMerchantConfig,
} from "../api/hooks.js";
import { runProtectedMutation } from "../api/contracts.js";
import type { PendingProtectedAction } from "../api/protectedAction.js";
@@ -30,7 +28,9 @@ import { AdminAccountDetailScreen } from "../screens/AdminAccountDetailScreen.js
import { AdminAccountCredentialsScreen } from "../screens/AdminAccountCredentialsScreen.js";
import { useTranslation } from "../context/translation.js";
-function reconfiguration(value: AdminAccountFormValue): TalerMerchantApi.InstanceReconfigurationMessage {
+function reconfiguration(
+ value: AdminAccountFormValue,
+): TalerMerchantApi.InstanceReconfigurationMessage {
return {
name: value.name,
email: value.email,
@@ -43,11 +43,15 @@ function reconfiguration(value: AdminAccountFormValue): TalerMerchantApi.Instanc
default_pay_delay: value.default_pay_delay,
default_refund_delay: value.default_refund_delay,
default_wire_transfer_delay: value.default_wire_transfer_delay,
- default_wire_transfer_rounding_interval: value.default_wire_transfer_rounding_interval,
+ default_wire_transfer_rounding_interval:
+ value.default_wire_transfer_rounding_interval,
};
}
-function formValue(instanceId: string, details: TalerMerchantApi.QueryInstancesResponse): AdminAccountFormValue {
+function formValue(
+ instanceId: string,
+ details: TalerMerchantApi.QueryInstancesResponse,
+): AdminAccountFormValue {
return {
id: instanceId,
name: details.name,
@@ -61,22 +65,34 @@ function formValue(instanceId: string, details: TalerMerchantApi.QueryInstancesR
default_pay_delay: details.default_pay_delay,
default_refund_delay: details.default_refund_delay,
default_wire_transfer_delay: details.default_wire_transfer_delay,
- default_wire_transfer_rounding_interval: details.default_wire_transfer_rounding_interval,
+ default_wire_transfer_rounding_interval:
+ details.default_wire_transfer_rounding_interval,
};
}
-export function AdminAccountsRoute({ onMfaRequired, onSignIn }: { onMfaRequired: (action: PendingProtectedAction) => void; onSignIn: (instanceId: string) => void }): VNode {
+export function AdminAccountsRoute({
+ onMfaRequired,
+ onSignIn,
+}: {
+ onMfaRequired: (action: PendingProtectedAction) => void;
+ onSignIn: (instanceId: string) => void;
+}): VNode {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const managed = useManagedInstances();
- const destructive = async (instance: TalerMerchantApi.Instance, purge: boolean) => {
+ const destructive = async (
+ instance: TalerMerchantApi.Instance,
+ purge: boolean,
+ ) => {
const result = await managed.deleteInstance(instance.id, purge);
if (result.challengeResponse) {
onMfaRequired({
accountLabel: instance.name,
challengeAccount: instance.id,
- actionNotice: purge ? t`Permanently purging merchant account ${instance.id}` : t`Disabling merchant account ${instance.id}`,
+ actionNotice: purge
+ ? t`Permanently purging merchant account ${instance.id}`
+ : t`Disabling merchant account ${instance.id}`,
challengeResponse: result.challengeResponse,
cancelTo: "/admin/accounts",
continueWith: (challengeIds) =>
@@ -98,13 +114,28 @@ export function AdminAccountsRoute({ onMfaRequired, onSignIn }: { onMfaRequired:
}
};
- return <AdminAccountsScreen instances={managed.instances} resource={managed.resource} accessDenied={managed.accessDenied} onCreate={() => setLocation("/admin/accounts/new")} onDisable={(instance) => destructive(instance, false)} onPurge={(instance) => destructive(instance, true)} onSignIn={(instance) => onSignIn(instance.id)} />;
+ return (
+ <AdminAccountsScreen
+ instances={managed.instances}
+ resource={managed.resource}
+ accessDenied={managed.accessDenied}
+ onCreate={() => setLocation("/admin/accounts/new")}
+ onDisable={(instance) => destructive(instance, false)}
+ onPurge={(instance) => destructive(instance, true)}
+ onSignIn={(instance) => onSignIn(instance.id)}
+ />
+ );
}
-export function AdminAccountCreateRoute({ onMfaRequired }: { onMfaRequired: (action: PendingProtectedAction) => void }): VNode {
+export function AdminAccountCreateRoute({
+ onMfaRequired,
+}: {
+ onMfaRequired: (action: PendingProtectedAction) => void;
+}): VNode {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const managed = useManagedInstances();
+ const { phoneRegex } = useMerchantConfig();
const submit = async (value: AdminAccountFormValue) => {
const payload: TalerMerchantApi.InstanceConfigurationMessage = {
...reconfiguration(value),
@@ -138,22 +169,72 @@ export function AdminAccountCreateRoute({ onMfaRequired }: { onMfaRequired: (act
}
setLocation("/admin/accounts");
};
- return <AdminAccountFormScreen mode="create" onSubmit={submit} onBack={() => setLocation("/admin/accounts")} />;
+ return (
+ <AdminAccountFormScreen
+ mode="create"
+ phoneRegex={phoneRegex}
+ onSubmit={submit}
+ onBack={() => setLocation("/admin/accounts")}
+ />
+ );
}
-export function AdminAccountDetailRoute({ instanceId, tab = "overview", onSignIn }: { instanceId: string; tab?: "overview" | "verification"; onSignIn: (instanceId: string) => void }): VNode {
+export function AdminAccountDetailRoute({
+ instanceId,
+ tab = "overview",
+ onSignIn,
+}: {
+ instanceId: string;
+ tab?: "overview" | "verification";
+ onSignIn: (instanceId: string) => void;
+}): VNode {
const [, setLocation] = useLocation();
const managed = useManagedInstance(instanceId);
const kyc = useManagedInstanceKyc(instanceId, tab === "verification");
- return <AdminAccountDetailScreen instanceId={instanceId} details={managed.details} resource={managed.resource} tab={tab} kyc={kyc.kyc} kycResource={tab === "verification" ? kyc.resource : undefined} onBack={() => setLocation("/admin/accounts")} onEdit={() => setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}/edit`)} onCredentials={() => setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}/credentials`)} onSignIn={() => onSignIn(instanceId)} />;
+ return (
+ <AdminAccountDetailScreen
+ instanceId={instanceId}
+ details={managed.details}
+ resource={managed.resource}
+ tab={tab}
+ kyc={kyc.kyc}
+ kycResource={tab === "verification" ? kyc.resource : undefined}
+ onBack={() => setLocation("/admin/accounts")}
+ onEdit={() =>
+ setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}/edit`)
+ }
+ onCredentials={() =>
+ setLocation(
+ `/admin/accounts/${encodeURIComponent(instanceId)}/credentials`,
+ )
+ }
+ onSignIn={() => onSignIn(instanceId)}
+ />
+ );
}
-export function AdminAccountEditRoute({ instanceId, onMfaRequired }: { instanceId: string; onMfaRequired: (action: PendingProtectedAction) => void }): VNode {
+export function AdminAccountEditRoute({
+ instanceId,
+ onMfaRequired,
+}: {
+ instanceId: string;
+ onMfaRequired: (action: PendingProtectedAction) => void;
+}): VNode {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const managed = useManagedInstance(instanceId);
+ const { phoneRegex } = useMerchantConfig();
if (!managed.details) {
- return <AdminAccountDetailScreen instanceId={instanceId} resource={managed.resource} onBack={() => setLocation("/admin/accounts")} onEdit={() => undefined} onCredentials={() => undefined} onSignIn={() => undefined} />;
+ return (
+ <AdminAccountDetailScreen
+ instanceId={instanceId}
+ resource={managed.resource}
+ onBack={() => setLocation("/admin/accounts")}
+ onEdit={() => undefined}
+ onCredentials={() => undefined}
+ onSignIn={() => undefined}
+ />
+ );
}
const submit = async (value: AdminAccountFormValue) => {
const payload = reconfiguration(value);
@@ -190,10 +271,26 @@ export function AdminAccountEditRoute({ instanceId, onMfaRequired }: { instanceI
() => formValue(instanceId, managed.details!),
[instanceId, managed.details],
);
- return <AdminAccountFormScreen mode="edit" initial={initial} onSubmit={submit} onBack={() => setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}`)} />;
+ return (
+ <AdminAccountFormScreen
+ mode="edit"
+ initial={initial}
+ phoneRegex={phoneRegex}
+ onSubmit={submit}
+ onBack={() =>
+ setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}`)
+ }
+ />
+ );
}
-export function AdminAccountCredentialsRoute({ instanceId, onMfaRequired }: { instanceId: string; onMfaRequired: (action: PendingProtectedAction) => void }): VNode {
+export function AdminAccountCredentialsRoute({
+ instanceId,
+ onMfaRequired,
+}: {
+ instanceId: string;
+ onMfaRequired: (action: PendingProtectedAction) => void;
+}): VNode {
const { t } = useTranslation();
const [, setLocation] = useLocation();
const managed = useManagedInstance(instanceId);
@@ -226,5 +323,13 @@ export function AdminAccountCredentialsRoute({ instanceId, onMfaRequired }: { in
}
setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}`);
};
- return <AdminAccountCredentialsScreen instanceId={instanceId} onBack={() => setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}`)} onReset={reset} />;
+ return (
+ <AdminAccountCredentialsScreen
+ instanceId={instanceId}
+ onBack={() =>
+ setLocation(`/admin/accounts/${encodeURIComponent(instanceId)}`)
+ }
+ onReset={reset}
+ />
+ );
}
diff --git a/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx b/packages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx
@@ -17,7 +17,7 @@
import type { VNode } from "preact";
import type { TalerMerchantApi } from "@gnu-taler/taler-util";
import { HttpStatusCode, MerchantAuthMethod } from "@gnu-taler/taler-util";
-import { useBusinessSettings } from "../api/hooks.js";
+import { useBusinessSettings, useMerchantConfig } from "../api/hooks.js";
import { merchantClient } from "../api/client.js";
import {
createPasswordVerifier,
@@ -41,15 +41,19 @@ export interface BusinessSettingsRouteProps {
onMfaRequired: (pending: PendingProtectedAction) => void;
}
-export function BusinessSettingsRoute({ onMfaRequired }: BusinessSettingsRouteProps): VNode {
+export function BusinessSettingsRoute({
+ onMfaRequired,
+}: BusinessSettingsRouteProps): VNode {
const { t } = useTranslation();
const { settings, updateSettings, resource } = useBusinessSettings();
+ const { phoneRegex } = useMerchantConfig();
const hasToken = Boolean(session.value.token);
return (
<BusinessSettingsScreen
settings={hasToken ? settings : undefined}
settingsResource={resource}
+ phoneRegex={phoneRegex}
passwordVerifierAvailable={Boolean(session.value.passwordVerifier)}
onSave={async (newSettings) => {
if (hasToken && updateSettings) {
@@ -124,7 +128,8 @@ export function BusinessSettingsRoute({ onMfaRequired }: BusinessSettingsRoutePr
onMfaRequired({
challengeAccount: currentAccount.value,
actionNotice: t`Changing merchant account password`,
- challengeResponse: changeRes.body as TalerMerchantApi.ChallengeResponse,
+ challengeResponse:
+ changeRes.body as TalerMerchantApi.ChallengeResponse,
cancelTo: "/settings/account",
continueWith: (challengeIds) =>
runProtectedMutation(async () => {
diff --git a/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx b/packages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx
@@ -15,7 +15,11 @@
*/
import type { ComponentChildren, VNode } from "preact";
-import { MerchantAuthMethod, Duration, TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ MerchantAuthMethod,
+ Duration,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
import { loginTokenLifetimeMinutes } from "../stores/devSettings.js";
import {
merchantManagementClient,
@@ -48,7 +52,9 @@ import { ReadErrorBanner } from "../ui/ReadErrorBanner.js";
* (api-merchant.rst 4.1, since v21). Presenting the ones solved while creating
* the account instead gets 410 Gone, because those are spent.
*/
-function tokenChallenges(res: unknown): TalerMerchantApi.ChallengeResponse | undefined {
+function tokenChallenges(
+ res: unknown,
+): TalerMerchantApi.ChallengeResponse | undefined {
const r = res as { case?: number; detail?: unknown };
if (r.case !== 202) return undefined;
return parseChallengeResponse(r.detail);
@@ -60,12 +66,15 @@ async function tokenFor(
challengeIds: string[] | undefined,
t?: TranslateFn,
): Promise<ProvisionAttempt> {
- const signedIn = await authenticate({
- account: req.account,
- secret: req.password,
- backendUrl: req.backendUrl,
- challengeIds,
- }, t);
+ const signedIn = await authenticate(
+ {
+ account: req.account,
+ secret: req.password,
+ backendUrl: req.backendUrl,
+ challengeIds,
+ },
+ t,
+ );
if (signedIn.type === "ok") {
return {
type: "ok",
@@ -94,14 +103,22 @@ function failureDetail(res: unknown): unknown {
return r.detail ?? r.body;
}
-export async function provision(req: ProvisionRequest, t?: TranslateFn): Promise<ProvisionAttempt> {
+export async function provision(
+ req: ProvisionRequest,
+ t?: TranslateFn,
+): Promise<ProvisionAttempt> {
let rootUrl: URL;
try {
rootUrl = new URL(req.backendUrl);
} catch (cause) {
return {
type: "fail",
- failure: configurationFailure(t ? t`The merchant backend URL is invalid.` : "The merchant backend URL is invalid.", cause),
+ failure: configurationFailure(
+ t
+ ? t`The merchant backend URL is invalid.`
+ : "The merchant backend URL is invalid.",
+ cause,
+ ),
};
}
@@ -138,9 +155,15 @@ export async function provision(req: ProvisionRequest, t?: TranslateFn): Promise
req.challengeIds && req.challengeIds.length > 0
? {
challengeIds: req.challengeIds,
- tokenValidity: Duration.fromMilliseconds(loginTokenLifetimeMinutes.value * 60 * 1000),
+ tokenValidity: Duration.fromMilliseconds(
+ loginTokenLifetimeMinutes.value * 60 * 1000,
+ ),
}
- : { tokenValidity: Duration.fromMilliseconds(loginTokenLifetimeMinutes.value * 60 * 1000) },
+ : {
+ tokenValidity: Duration.fromMilliseconds(
+ loginTokenLifetimeMinutes.value * 60 * 1000,
+ ),
+ },
);
if (resp.type === "fail") {
@@ -190,19 +213,27 @@ export async function provision(req: ProvisionRequest, t?: TranslateFn): Promise
export interface SignupPolicy {
available: boolean;
mandatoryTanChannels: ("email" | "sms")[];
+ phoneRegex?: string;
}
/** Interpret public configuration conservatively: absent or false means closed. */
-export function signupPolicy(config: {
- haveSelfProvisioning?: boolean;
- mandatoryTanChannels?: TalerMerchantApi.TanChannel[];
-} | undefined): SignupPolicy {
+export function signupPolicy(
+ config:
+ | {
+ haveSelfProvisioning?: boolean;
+ mandatoryTanChannels?: TalerMerchantApi.TanChannel[];
+ phoneRegex?: string;
+ }
+ | undefined,
+): SignupPolicy {
return {
available: config?.haveSelfProvisioning === true,
mandatoryTanChannels: (config?.mandatoryTanChannels ?? []).filter(
- (channel) => channel === TalerMerchantApi.TanChannel.EMAIL ||
+ (channel) =>
+ channel === TalerMerchantApi.TanChannel.EMAIL ||
channel === TalerMerchantApi.TanChannel.SMS,
) as ("email" | "sms")[],
+ phoneRegex: config?.phoneRegex,
};
}
@@ -214,7 +245,10 @@ function SignupGate({ children }: { children: ComponentChildren }): VNode {
<div class="sm:mx-auto sm:w-full sm:max-w-md text-center">
<TalerLogo class="h-10 w-auto mx-auto text-taler-brand" />
<div class="mt-6">{children}</div>
- <a href="#/signin" class="mt-4 inline-block text-xs font-semibold text-taler-brand hover:underline">
+ <a
+ href="#/signin"
+ class="mt-4 inline-block text-xs font-semibold text-taler-brand hover:underline"
+ >
{t`Merchant Portal Sign-In`}
</a>
</div>
@@ -265,6 +299,7 @@ export function SelfProvisionRoute(props: SelfProvisionScreenProps): VNode {
<SelfProvisionScreen
{...props}
mandatoryTanChannels={policy.mandatoryTanChannels}
+ phoneRegex={policy.phoneRegex}
onProvision={(request) => provision(request, t)}
onSendChallenge={async (challengeId, backendUrl) => {
const rootUrl = new URL(backendUrl);
@@ -275,7 +310,13 @@ export function SelfProvisionRoute(props: SelfProvisionScreenProps): VNode {
onSolveChallenge={async (challengeId, code, backendUrl) => {
try {
const rootUrl = new URL(backendUrl);
- return await confirmTanChallenge({ rootUrl }, challengeId, code, false, t);
+ return await confirmTanChallenge(
+ { rootUrl },
+ challengeId,
+ code,
+ false,
+ t,
+ );
} catch (cause) {
return { ok: false, failure: cause };
}
diff --git a/packages/taler-merchant-webui/src/routes/selfProvision.test.ts b/packages/taler-merchant-webui/src/routes/selfProvision.test.ts
@@ -23,7 +23,11 @@
import { test } from "node:test";
import assert from "node:assert";
-import { HttpStatusCode, TalerErrorCode, TalerMerchantApi } from "@gnu-taler/taler-util";
+import {
+ HttpStatusCode,
+ TalerErrorCode,
+ TalerMerchantApi,
+} from "@gnu-taler/taler-util";
import { FakeHttpLib, ok, talerError } from "../testing/fake-http.js";
import { useHttpLibForTesting } from "../api/client.js";
import { provision, signupPolicy } from "./SelfProvisionRoute.js";
@@ -52,7 +56,14 @@ const REQ = {
};
test("instance identifiers accept exactly the backend slug alphabet", () => {
- for (const accepted of ["a", "Shop-1", "shop_1", "shop.1", "shop:1", "A0_.:-"]) {
+ for (const accepted of [
+ "a",
+ "Shop-1",
+ "shop_1",
+ "shop.1",
+ "shop:1",
+ "A0_.:-",
+ ]) {
assert.equal(isValidInstanceId(accepted), true, accepted);
}
for (const rejected of ["", ".", "..", "shop name", "shop/1", "ümlaut"]) {
@@ -64,18 +75,34 @@ test("signup fails closed and requires exactly the configured TAN channels", ()
assert.deepEqual(signupPolicy(undefined), {
available: false,
mandatoryTanChannels: [],
+ phoneRegex: undefined,
});
- assert.deepEqual(signupPolicy({
- haveSelfProvisioning: true,
- mandatoryTanChannels: [TalerMerchantApi.TanChannel.SMS],
- }), { available: true, mandatoryTanChannels: ["sms"] });
- assert.deepEqual(signupPolicy({
- haveSelfProvisioning: false,
- mandatoryTanChannels: [
- TalerMerchantApi.TanChannel.EMAIL,
- TalerMerchantApi.TanChannel.SMS,
- ],
- }), { available: false, mandatoryTanChannels: ["email", "sms"] });
+ assert.deepEqual(
+ signupPolicy({
+ haveSelfProvisioning: true,
+ mandatoryTanChannels: [TalerMerchantApi.TanChannel.SMS],
+ phoneRegex: "^\\+41[0-9]+$",
+ }),
+ {
+ available: true,
+ mandatoryTanChannels: ["sms"],
+ phoneRegex: "^\\+41[0-9]+$",
+ },
+ );
+ assert.deepEqual(
+ signupPolicy({
+ haveSelfProvisioning: false,
+ mandatoryTanChannels: [
+ TalerMerchantApi.TanChannel.EMAIL,
+ TalerMerchantApi.TanChannel.SMS,
+ ],
+ }),
+ {
+ available: false,
+ mandatoryTanChannels: ["email", "sms"],
+ phoneRegex: undefined,
+ },
+ );
});
test("registering yields a login token, never the chosen password", async () => {
@@ -89,7 +116,11 @@ test("registering yields a login token, never the chosen password", async () =>
assert.equal(res.type, "ok", JSON.stringify(res));
if (res.type !== "ok") return;
assert.equal(res.token, "secret-token:NEWACCOUNT");
- assert.notEqual(res.token, REQ.password, "the password must never become the credential");
+ assert.notEqual(
+ res.token,
+ REQ.password,
+ "the password must never become the credential",
+ );
assert.equal(res.tokenInfo?.refreshable, true);
assert.ok(res.passwordVerifier);
assert.equal(
@@ -125,7 +156,10 @@ test("a registration that issues no token exchanges the password for one", async
});
test("a taken username comes back as 409 so the screen can say so", async () => {
- const http = new FakeHttpLib().otherwise({ status: HttpStatusCode.Conflict, body: { code: 2, hint: "taken" } });
+ const http = new FakeHttpLib().otherwise({
+ status: HttpStatusCode.Conflict,
+ body: { code: 2, hint: "taken" },
+ });
const restore = useHttpLibForTesting(http);
try {
const res = await provision(REQ);
@@ -145,8 +179,16 @@ test("a second factor demand carries its challenges through", async () => {
status: HttpStatusCode.Accepted,
body: {
challenges: [
- { challenge_id: "CH-EMAIL", tan_channel: "email", tan_info: "shop@alpenblick.example" },
- { challenge_id: "CH-SMS", tan_channel: "sms", tan_info: "+41790000000" },
+ {
+ challenge_id: "CH-EMAIL",
+ tan_channel: "email",
+ tan_info: "shop@alpenblick.example",
+ },
+ {
+ challenge_id: "CH-SMS",
+ tan_channel: "sms",
+ tan_info: "+41790000000",
+ },
],
combi_and: true,
},
@@ -169,7 +211,10 @@ test("solved challenges are presented when finalising", async () => {
const http = new FakeHttpLib().on("POST", "/instances", ok(issuedToken()));
const restore = useHttpLibForTesting(http);
try {
- const res = await provision({ ...REQ, challengeIds: ["CH-EMAIL", "CH-SMS"] });
+ const res = await provision({
+ ...REQ,
+ challengeIds: ["CH-EMAIL", "CH-SMS"],
+ });
assert.equal(res.type, "ok");
assert.match(
String(http.lastRequest!.headers?.["Taler-Challenge-Ids"]),
@@ -197,7 +242,10 @@ test("an unreachable server does not report an account as created", async () =>
test("an HTTP 500 does not advance registration to verification", async () => {
const http = new FakeHttpLib().on("POST", "/instances", {
status: HttpStatusCode.InternalServerError,
- body: talerError(TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE, "database unavailable"),
+ body: talerError(
+ TalerErrorCode.GENERIC_INTERNAL_INVARIANT_FAILURE,
+ "database unavailable",
+ ),
});
const restore = useHttpLibForTesting(http);
try {
@@ -234,7 +282,10 @@ test("a token refused for a second factor asks for the new challenges, not the s
});
const restore = useHttpLibForTesting(http);
try {
- const res = await provision({ ...REQ, challengeIds: ["CH-EMAIL", "CH-SMS"] });
+ const res = await provision({
+ ...REQ,
+ challengeIds: ["CH-EMAIL", "CH-SMS"],
+ });
assert.equal(res.type, "verify-token", JSON.stringify(res));
if (res.type !== "verify-token") return;
assert.deepEqual(res.challengeResponse, {
@@ -244,7 +295,9 @@ test("a token refused for a second factor asks for the new challenges, not the s
],
combi_and: false,
});
- const tokenReq = http.requests.find((r) => r.url.includes("/private/token"))!;
+ const tokenReq = http.requests.find((r) =>
+ r.url.includes("/private/token"),
+ )!;
assert.equal(
tokenReq.headers?.["Taler-Challenge-Ids"],
undefined,
@@ -256,16 +309,27 @@ test("a token refused for a second factor asks for the new challenges, not the s
});
test("the token round finishes without creating the account a second time", async () => {
- const http = new FakeHttpLib().on("POST", "/private/token", ok(issuedToken()));
+ const http = new FakeHttpLib().on(
+ "POST",
+ "/private/token",
+ ok(issuedToken()),
+ );
const restore = useHttpLibForTesting(http);
try {
const res = await provision({ ...REQ, tokenChallengeIds: ["TOK-EMAIL"] });
assert.equal(res.type, "ok", JSON.stringify(res));
if (res.type !== "ok") return;
assert.equal(res.token, "secret-token:NEWACCOUNT");
- assert.equal(http.requests.length, 1, "only the token request, the account already exists");
+ assert.equal(
+ http.requests.length,
+ 1,
+ "only the token request, the account already exists",
+ );
assert.match(String(http.lastRequest!.url), /\/private\/token/);
- assert.equal(http.lastRequest!.headers?.["Taler-Challenge-Ids"], "TOK-EMAIL");
+ assert.equal(
+ http.lastRequest!.headers?.["Taler-Challenge-Ids"],
+ "TOK-EMAIL",
+ );
} finally {
restore();
}
@@ -276,7 +340,10 @@ test("a token refused for any other reason still reports the account as created"
// already exists; the sign-in screen takes it from here.
const http = new FakeHttpLib()
.on("POST", "/instances", { status: HttpStatusCode.NoContent })
- .on("POST", "/private/token", { status: HttpStatusCode.Unauthorized, body: { code: 40 } });
+ .on("POST", "/private/token", {
+ status: HttpStatusCode.Unauthorized,
+ body: { code: 40 },
+ });
const restore = useHttpLibForTesting(http);
try {
const res = await provision(REQ);
diff --git a/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx b/packages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx
@@ -24,6 +24,7 @@ import { useImageDataUrlStatus } from "../ui/imageDataUrl.js";
import { DurationInput, type DurationValue } from "../ui/DurationInput.js";
import { PasswordInput } from "../ui/PasswordInput.js";
import { useTranslation } from "../context/translation.js";
+import { isValidMfaPhone } from "../utils/phone.js";
export interface AdminAccountFormValue {
id: string;
@@ -45,6 +46,7 @@ export interface AdminAccountFormValue {
export interface AdminAccountFormScreenProps {
mode: "create" | "edit";
initial?: AdminAccountFormValue;
+ phoneRegex?: string;
onSubmit: (value: AdminAccountFormValue) => Promise<void>;
onBack: () => void;
}
@@ -62,26 +64,53 @@ const EMPTY: AdminAccountFormValue = {
use_stefan: true,
};
-function durationUs(value: TalerProtocolDuration | undefined, fallback: number): DurationValue {
+function durationUs(
+ value: TalerProtocolDuration | undefined,
+ fallback: number,
+): DurationValue {
return value?.d_us ?? fallback;
}
-export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack }: AdminAccountFormScreenProps): VNode {
+export function AdminAccountFormScreen({
+ mode,
+ initial = EMPTY,
+ phoneRegex,
+ onSubmit,
+ onBack,
+}: AdminAccountFormScreenProps): VNode {
const { t } = useTranslation();
const [value, setValue] = useState<AdminAccountFormValue>(initial);
const [confirmPassword, setConfirmPassword] = useState("");
const [showAdvanced, setShowAdvanced] = useState(mode === "edit");
- const [customTiming, setCustomTiming] = useState(mode === "edit" || Boolean(initial.default_pay_delay || initial.default_refund_delay || initial.default_wire_transfer_delay));
- const [payDelayUs, setPayDelayUs] = useState(durationUs(initial.default_pay_delay, 86_400_000_000));
- const [refundDelayUs, setRefundDelayUs] = useState(durationUs(initial.default_refund_delay, 0));
- const [wireDelayUs, setWireDelayUs] = useState(durationUs(initial.default_wire_transfer_delay, 86_400_000_000));
+ const [customTiming, setCustomTiming] = useState(
+ mode === "edit" ||
+ Boolean(
+ initial.default_pay_delay ||
+ initial.default_refund_delay ||
+ initial.default_wire_transfer_delay,
+ ),
+ );
+ const [payDelayUs, setPayDelayUs] = useState(
+ durationUs(initial.default_pay_delay, 86_400_000_000),
+ );
+ const [refundDelayUs, setRefundDelayUs] = useState(
+ durationUs(initial.default_refund_delay, 0),
+ );
+ const [wireDelayUs, setWireDelayUs] = useState(
+ durationUs(initial.default_wire_transfer_delay, 86_400_000_000),
+ );
const [timingValid, setTimingValid] = useState([true, true, true]);
- const [rounding, setRounding] = useState<string>(initial.default_wire_transfer_rounding_interval || "NONE");
+ const [rounding, setRounding] = useState<string>(
+ initial.default_wire_transfer_rounding_interval || "NONE",
+ );
const [error, setError] = useState<unknown>();
const [validation, setValidation] = useState("");
const [pending, setPending] = useState(false);
const logoStatus = useImageDataUrlStatus(value.logo || "");
const hydratedIdentity = useRef<string | undefined>(undefined);
+ const isPhoneInvalid =
+ Boolean(value.phone_number) &&
+ !isValidMfaPhone(value.phone_number!, phoneRegex);
useEffect(() => {
const identity = `${mode}:${initial.id}`;
@@ -90,7 +119,9 @@ export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack
setValue(initial);
setPayDelayUs(durationUs(initial.default_pay_delay, 86_400_000_000));
setRefundDelayUs(durationUs(initial.default_refund_delay, 0));
- setWireDelayUs(durationUs(initial.default_wire_transfer_delay, 86_400_000_000));
+ setWireDelayUs(
+ durationUs(initial.default_wire_transfer_delay, 86_400_000_000),
+ );
setRounding(initial.default_wire_transfer_rounding_interval || "NONE");
}, [initial, mode]);
@@ -98,7 +129,10 @@ export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack
if (logoStatus === "invalid") setShowAdvanced(true);
}, [logoStatus]);
- const change = <K extends keyof AdminAccountFormValue>(key: K, next: AdminAccountFormValue[K]) => setValue((old) => ({ ...old, [key]: next }));
+ const change = <K extends keyof AdminAccountFormValue>(
+ key: K,
+ next: AdminAccountFormValue[K],
+ ) => setValue((old) => ({ ...old, [key]: next }));
const submit = async (event: Event) => {
event.preventDefault();
@@ -113,6 +147,10 @@ export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack
setValidation(t`Business name is required.`);
return;
}
+ if (isPhoneInvalid) {
+ setValidation(t`Invalid phone number`);
+ return;
+ }
if (logoStatus === "checking" || logoStatus === "invalid") {
setValidation(t`Remove or replace the logo before saving.`);
return;
@@ -140,17 +178,20 @@ export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack
phone_number: value.phone_number?.trim() || undefined,
website: value.website?.trim() || undefined,
logo: value.logo?.trim() || undefined,
- ...(customTiming ? {
- default_pay_delay: { d_us: payDelayUs },
- default_refund_delay: { d_us: refundDelayUs },
- default_wire_transfer_delay: { d_us: wireDelayUs },
- default_wire_transfer_rounding_interval: rounding as TalerMerchantApi.RoundingInterval,
- } : {
- default_pay_delay: undefined,
- default_refund_delay: undefined,
- default_wire_transfer_delay: undefined,
- default_wire_transfer_rounding_interval: undefined,
- }),
+ ...(customTiming
+ ? {
+ default_pay_delay: { d_us: payDelayUs },
+ default_refund_delay: { d_us: refundDelayUs },
+ default_wire_transfer_delay: { d_us: wireDelayUs },
+ default_wire_transfer_rounding_interval:
+ rounding as TalerMerchantApi.RoundingInterval,
+ }
+ : {
+ default_pay_delay: undefined,
+ default_refund_delay: undefined,
+ default_wire_transfer_delay: undefined,
+ default_wire_transfer_rounding_interval: undefined,
+ }),
};
setPending(true);
try {
@@ -166,34 +207,289 @@ export function AdminAccountFormScreen({ mode, initial = EMPTY, onSubmit, onBack
return (
<div class="max-w-4xl space-y-5">
- <Header title={mode === "create" ? t`Create merchant account` : t`Edit merchant account`} subtitle={mode === "create" ? t`Set up another merchant account on this server.` : t`Update this account’s public identity and operating defaults.`} onBack={onBack} backLabel={t`Merchant accounts`} />
- <ApiErrorBanner error={error} title={mode === "create" ? t`Could not create merchant account` : t`Could not update merchant account`} />
- {validation && <div data-error-banner role="alert" class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-800">{validation}</div>}
+ <Header
+ title={
+ mode === "create"
+ ? t`Create merchant account`
+ : t`Edit merchant account`
+ }
+ subtitle={
+ mode === "create"
+ ? t`Set up another merchant account on this server.`
+ : t`Update this account’s public identity and operating defaults.`
+ }
+ onBack={onBack}
+ backLabel={t`Merchant accounts`}
+ />
+ <ApiErrorBanner
+ error={error}
+ title={
+ mode === "create"
+ ? t`Could not create merchant account`
+ : t`Could not update merchant account`
+ }
+ />
+ {validation && (
+ <div
+ data-error-banner
+ role="alert"
+ class="rounded-lg border border-red-200 bg-red-50 p-3 text-sm font-semibold text-red-800"
+ >
+ {validation}
+ </div>
+ )}
<form onSubmit={(event) => void submit(event)} class="space-y-5">
<section class="space-y-4 rounded-xl border border-gray-200 bg-white p-5 shadow-2xs">
- <div><h2 class="font-bold text-gray-950">{t`Account identity`}</h2><p class="mt-0.5 text-xs text-gray-500">{t`The account identifier is used in server URLs; the business name is shown to customers.`}</p></div>
+ <div>
+ <h2 class="font-bold text-gray-950">{t`Account identity`}</h2>
+ <p class="mt-0.5 text-xs text-gray-500">{t`The account identifier is used in server URLs; the business name is shown to customers.`}</p>
+ </div>
<div class="grid gap-4 sm:grid-cols-2">
- <div><label htmlFor="managed-account-id" class="block text-xs font-bold text-gray-700">{t`Account ID`} *</label><input id="managed-account-id" value={value.id} disabled={mode === "edit"} onInput={(event) => change("id", event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm disabled:bg-gray-100" required /></div>
- <div><label htmlFor="managed-business-name" class="block text-xs font-bold text-gray-700">{t`Business name`} *</label><input id="managed-business-name" value={value.name} onInput={(event) => change("name", event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" required /></div>
- <div><label htmlFor="managed-email" class="block text-xs font-bold text-gray-700">{t`Email address`}</label><input id="managed-email" type="email" value={value.email || ""} onInput={(event) => change("email", event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" /></div>
- <div><label htmlFor="managed-phone" class="block text-xs font-bold text-gray-700">{t`Mobile phone number`}</label><input id="managed-phone" type="tel" value={value.phone_number || ""} onInput={(event) => change("phone_number", event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" /></div>
+ <div>
+ <label
+ htmlFor="managed-account-id"
+ class="block text-xs font-bold text-gray-700"
+ >
+ {t`Account ID`} *
+ </label>
+ <input
+ id="managed-account-id"
+ value={value.id}
+ disabled={mode === "edit"}
+ onInput={(event) => change("id", event.currentTarget.value)}
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 font-mono text-sm disabled:bg-gray-100"
+ required
+ />
+ </div>
+ <div>
+ <label
+ htmlFor="managed-business-name"
+ class="block text-xs font-bold text-gray-700"
+ >
+ {t`Business name`} *
+ </label>
+ <input
+ id="managed-business-name"
+ value={value.name}
+ onInput={(event) => change("name", event.currentTarget.value)}
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ required
+ />
+ </div>
+ <div>
+ <label
+ htmlFor="managed-email"
+ class="block text-xs font-bold text-gray-700"
+ >{t`Email address`}</label>
+ <input
+ id="managed-email"
+ type="email"
+ value={value.email || ""}
+ onInput={(event) => change("email", event.currentTarget.value)}
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ />
+ </div>
+ <div>
+ <label
+ htmlFor="managed-phone"
+ class="block text-xs font-bold text-gray-700"
+ >{t`Mobile phone number`}</label>
+ <input
+ id="managed-phone"
+ type="tel"
+ value={value.phone_number || ""}
+ onInput={(event) =>
+ change("phone_number", event.currentTarget.value)
+ }
+ aria-invalid={isPhoneInvalid}
+ aria-describedby="managed-phone-help"
+ class={`mt-1 w-full rounded-lg border px-3 py-2 text-sm ${isPhoneInvalid ? "border-red-500" : "border-gray-300"}`}
+ />
+ {isPhoneInvalid && (
+ <p
+ id="managed-phone-help"
+ class="mt-1 text-xs font-semibold text-red-700"
+ >{t`Invalid phone number`}</p>
+ )}
+ </div>
</div>
- {mode === "create" && <div class="grid gap-4 border-t border-gray-100 pt-4 sm:grid-cols-2"><PasswordInput id="managed-password" label={t`Password`} value={value.password || ""} onInput={(password) => change("password", password)} autoComplete="new-password" required minLength={8} /><PasswordInput id="managed-password-confirm" label={t`Confirm password`} value={confirmPassword} onInput={setConfirmPassword} autoComplete="new-password" required minLength={8} /></div>}
+ {mode === "create" && (
+ <div class="grid gap-4 border-t border-gray-100 pt-4 sm:grid-cols-2">
+ <PasswordInput
+ id="managed-password"
+ label={t`Password`}
+ value={value.password || ""}
+ onInput={(password) => change("password", password)}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ <PasswordInput
+ id="managed-password-confirm"
+ label={t`Confirm password`}
+ value={confirmPassword}
+ onInput={setConfirmPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ </div>
+ )}
</section>
- <button type="button" onClick={() => setShowAdvanced(!showAdvanced)} class="flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white p-4 text-left text-sm font-bold text-gray-900 hover:bg-gray-50"><span>{t`Advanced business configuration`}</span><span aria-hidden="true">{showAdvanced ? "▴" : "▾"}</span></button>
+ <button
+ type="button"
+ onClick={() => setShowAdvanced(!showAdvanced)}
+ class="flex w-full items-center justify-between rounded-xl border border-gray-200 bg-white p-4 text-left text-sm font-bold text-gray-900 hover:bg-gray-50"
+ >
+ <span>{t`Advanced business configuration`}</span>
+ <span aria-hidden="true">{showAdvanced ? "▴" : "▾"}</span>
+ </button>
{showAdvanced && (
<section class="space-y-6 rounded-xl border border-gray-200 bg-white p-5 shadow-2xs">
- <div class="grid gap-4 sm:grid-cols-2"><div><label htmlFor="managed-website" class="block text-xs font-bold text-gray-700">{t`Website URL`}</label><input id="managed-website" type="url" value={value.website || ""} onInput={(event) => change("website", event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" /></div><ImageUploadInput id="managed-logo" label={t`Logo`} helpText={t`Shown on payment pages and receipts.`} value={value.logo || ""} onChange={(next) => change("logo", next)} /></div>
- <LocationInput idPrefix="managed-physical-address" label={t`Physical merchant address`} value={value.address} onChange={(next) => change("address", next)} />
- <LocationInput idPrefix="managed-legal-jurisdiction" label={t`Legal jurisdiction`} value={value.jurisdiction} onChange={(next) => change("jurisdiction", next)} />
- <div class="flex items-start gap-3 text-sm"><input id="managed-use-stefan" type="checkbox" checked={value.use_stefan} onChange={(event) => change("use_stefan", event.currentTarget.checked)} class="mt-0.5" /><label htmlFor="managed-use-stefan"><strong class="block">{t`Cover transaction fees`}</strong><span class="text-xs text-gray-500">{t`Use STEFAN curves to determine acceptable default fees.`}</span></label></div>
- <div class="flex items-start gap-3 border-t border-gray-100 pt-5 text-sm"><input id="managed-custom-timing" type="checkbox" checked={customTiming} onChange={(event) => setCustomTiming(event.currentTarget.checked)} class="mt-0.5" /><label htmlFor="managed-custom-timing"><strong class="block">{t`Override server timing defaults`}</strong><span class="text-xs text-gray-500">{t`Leave this off during creation to inherit the merchant backend defaults.`}</span></label></div>
- {customTiming && <div class="grid gap-5 sm:grid-cols-2"><DurationInput id="managed-pay-delay" label={t`Time to pay`} valueUs={payDelayUs} onChangeUs={setPayDelayUs} onValidityChange={(valid) => setTimingValid((all) => [valid, all[1]!, all[2]!])} required /><DurationInput id="managed-refund-delay" label={t`Refund window`} valueUs={refundDelayUs} onChangeUs={setRefundDelayUs} onValidityChange={(valid) => setTimingValid((all) => [all[0]!, valid, all[2]!])} required /><DurationInput id="managed-wire-delay" label={t`Payout delay`} valueUs={wireDelayUs} onChangeUs={setWireDelayUs} onValidityChange={(valid) => setTimingValid((all) => [all[0]!, all[1]!, valid])} required /><div><label htmlFor="managed-rounding" class="block text-xs font-bold text-gray-700">{t`Payout deadline rounding`}</label><select id="managed-rounding" value={rounding} onChange={(event) => setRounding(event.currentTarget.value)} class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"><option value="NONE">{t`No rounding (exact time)`}</option><option value="SECOND">{t`Round to nearest second`}</option><option value="MINUTE">{t`Round to nearest minute`}</option><option value="HOUR">{t`Round to nearest hour`}</option><option value="DAY">{t`Round to end of day (midnight)`}</option><option value="WEEK">{t`Round to end of week`}</option><option value="MONTH">{t`Round to end of month`}</option><option value="QUARTER">{t`Round to end of quarter`}</option><option value="YEAR">{t`Round to end of year`}</option></select></div></div>}
+ <div class="grid gap-4 sm:grid-cols-2">
+ <div>
+ <label
+ htmlFor="managed-website"
+ class="block text-xs font-bold text-gray-700"
+ >{t`Website URL`}</label>
+ <input
+ id="managed-website"
+ type="url"
+ value={value.website || ""}
+ onInput={(event) =>
+ change("website", event.currentTarget.value)
+ }
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ />
+ </div>
+ <ImageUploadInput
+ id="managed-logo"
+ label={t`Logo`}
+ helpText={t`Shown on payment pages and receipts.`}
+ value={value.logo || ""}
+ onChange={(next) => change("logo", next)}
+ />
+ </div>
+ <LocationInput
+ idPrefix="managed-physical-address"
+ label={t`Physical merchant address`}
+ value={value.address}
+ onChange={(next) => change("address", next)}
+ />
+ <LocationInput
+ idPrefix="managed-legal-jurisdiction"
+ label={t`Legal jurisdiction`}
+ value={value.jurisdiction}
+ onChange={(next) => change("jurisdiction", next)}
+ />
+ <div class="flex items-start gap-3 text-sm">
+ <input
+ id="managed-use-stefan"
+ type="checkbox"
+ checked={value.use_stefan}
+ onChange={(event) =>
+ change("use_stefan", event.currentTarget.checked)
+ }
+ class="mt-0.5"
+ />
+ <label htmlFor="managed-use-stefan">
+ <strong class="block">{t`Cover transaction fees`}</strong>
+ <span class="text-xs text-gray-500">{t`Use STEFAN curves to determine acceptable default fees.`}</span>
+ </label>
+ </div>
+ <div class="flex items-start gap-3 border-t border-gray-100 pt-5 text-sm">
+ <input
+ id="managed-custom-timing"
+ type="checkbox"
+ checked={customTiming}
+ onChange={(event) =>
+ setCustomTiming(event.currentTarget.checked)
+ }
+ class="mt-0.5"
+ />
+ <label htmlFor="managed-custom-timing">
+ <strong class="block">{t`Override server timing defaults`}</strong>
+ <span class="text-xs text-gray-500">{t`Leave this off during creation to inherit the merchant backend defaults.`}</span>
+ </label>
+ </div>
+ {customTiming && (
+ <div class="grid gap-5 sm:grid-cols-2">
+ <DurationInput
+ id="managed-pay-delay"
+ label={t`Time to pay`}
+ valueUs={payDelayUs}
+ onChangeUs={setPayDelayUs}
+ onValidityChange={(valid) =>
+ setTimingValid((all) => [valid, all[1]!, all[2]!])
+ }
+ required
+ />
+ <DurationInput
+ id="managed-refund-delay"
+ label={t`Refund window`}
+ valueUs={refundDelayUs}
+ onChangeUs={setRefundDelayUs}
+ onValidityChange={(valid) =>
+ setTimingValid((all) => [all[0]!, valid, all[2]!])
+ }
+ required
+ />
+ <DurationInput
+ id="managed-wire-delay"
+ label={t`Payout delay`}
+ valueUs={wireDelayUs}
+ onChangeUs={setWireDelayUs}
+ onValidityChange={(valid) =>
+ setTimingValid((all) => [all[0]!, all[1]!, valid])
+ }
+ required
+ />
+ <div>
+ <label
+ htmlFor="managed-rounding"
+ class="block text-xs font-bold text-gray-700"
+ >{t`Payout deadline rounding`}</label>
+ <select
+ id="managed-rounding"
+ value={rounding}
+ onChange={(event) => setRounding(event.currentTarget.value)}
+ class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
+ >
+ <option value="NONE">{t`No rounding (exact time)`}</option>
+ <option value="SECOND">{t`Round to nearest second`}</option>
+ <option value="MINUTE">{t`Round to nearest minute`}</option>
+ <option value="HOUR">{t`Round to nearest hour`}</option>
+ <option value="DAY">{t`Round to end of day (midnight)`}</option>
+ <option value="WEEK">{t`Round to end of week`}</option>
+ <option value="MONTH">{t`Round to end of month`}</option>
+ <option value="QUARTER">{t`Round to end of quarter`}</option>
+ <option value="YEAR">{t`Round to end of year`}</option>
+ </select>
+ </div>
+ </div>
+ )}
</section>
)}
- <div class="flex justify-end gap-3"><Button variant="secondary" onClick={onBack} disabled={pending}>{t`Cancel`}</Button><Button type="submit" isLoading={pending} disabled={(customTiming && timingValid.some((valid) => !valid)) || logoStatus === "checking" || logoStatus === "invalid"}>{mode === "create" ? t`Create merchant account` : t`Save changes`}</Button></div>
+ <div class="flex justify-end gap-3">
+ <Button
+ variant="secondary"
+ onClick={onBack}
+ disabled={pending}
+ >{t`Cancel`}</Button>
+ <Button
+ type="submit"
+ isLoading={pending}
+ disabled={
+ (customTiming && timingValid.some((valid) => !valid)) ||
+ logoStatus === "checking" ||
+ logoStatus === "invalid"
+ }
+ >
+ {mode === "create" ? t`Create merchant account` : t`Save changes`}
+ </Button>
+ </div>
</form>
</div>
);
diff --git a/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx b/packages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx
@@ -24,6 +24,7 @@ import { useImageDataUrlStatus } from "../ui/imageDataUrl.js";
import { useTranslation, type TranslateFn } from "../context/translation.js";
import type { RemoteResource } from "../api/contracts.js";
import type { BusinessSettings } from "../types/domain.js";
+import { isValidMfaPhone } from "../utils/phone.js";
export type { BusinessSettings };
export type BusinessSettingsSaveResult = "saved" | "challenge";
@@ -44,6 +45,7 @@ type EditorSection =
export interface BusinessSettingsScreenProps {
settings?: BusinessSettings;
settingsResource?: RemoteResource<BusinessSettings>;
+ phoneRegex?: string;
onSave?: (settings: BusinessSettings) => Promise<BusinessSettingsSaveResult>;
passwordVerifierAvailable?: boolean;
onChangePassword?: (data: {
@@ -80,7 +82,9 @@ const CLOSED_EDITORS: Record<EditorSection, boolean> = {
security: false,
};
-function editorState(initialSection: BusinessSettingsScreenProps["initialSection"]): Record<EditorSection, boolean> {
+function editorState(
+ initialSection: BusinessSettingsScreenProps["initialSection"],
+): Record<EditorSection, boolean> {
return {
...CLOSED_EDITORS,
profile: initialSection === "profile",
@@ -111,12 +115,21 @@ function formatUsToDuration(us: DurationValue, t: TranslateFn): string {
return seconds === 1 ? t`1 second` : t`${seconds} seconds`;
}
-function formatStructuredAddressLines(location: TalerMerchantApi.Location | undefined): string[] {
+function formatStructuredAddressLines(
+ location: TalerMerchantApi.Location | undefined,
+): string[] {
if (!location) return [];
const lines: string[] = [];
- const street = [location.street, location.building_number].filter(Boolean).join(" ");
+ const street = [location.street, location.building_number]
+ .filter(Boolean)
+ .join(" ");
if (street) lines.push(street);
- const building = [location.building_name, location.address_lines?.filter(Boolean).join(", ")].filter(Boolean).join(" ");
+ const building = [
+ location.building_name,
+ location.address_lines?.filter(Boolean).join(", "),
+ ]
+ .filter(Boolean)
+ .join(" ");
if (building) lines.push(building);
const town = [location.post_code, location.town].filter(Boolean).join(" ");
if (town) lines.push(town);
@@ -138,10 +151,15 @@ function DisclosureSection(props: DisclosureSectionProps): VNode {
const { t } = useTranslation();
const content = (
<div class="flex min-w-0 items-start gap-3">
- <DisclosureChevron open={props.isOpen} class={`mt-0.5 ${props.isOpen ? "text-blue-600" : "text-gray-500"}`} />
+ <DisclosureChevron
+ open={props.isOpen}
+ class={`mt-0.5 ${props.isOpen ? "text-blue-600" : "text-gray-500"}`}
+ />
<div class="min-w-0">
<h3 class="text-sm font-bold text-gray-900">{props.title}</h3>
- <div class="mt-1 text-xs text-gray-600">{props.isOpen ? props.subtitle : props.summary}</div>
+ <div class="mt-1 text-xs text-gray-600">
+ {props.isOpen ? props.subtitle : props.summary}
+ </div>
</div>
</div>
);
@@ -165,11 +183,18 @@ function DisclosureSection(props: DisclosureSectionProps): VNode {
</button>
)}
{props.saved && !props.isOpen && (
- <div role="status" class="border-t border-emerald-200 bg-emerald-50 px-5 py-2 text-xs font-semibold text-emerald-800">
+ <div
+ role="status"
+ class="border-t border-emerald-200 bg-emerald-50 px-5 py-2 text-xs font-semibold text-emerald-800"
+ >
{t`Changes saved.`}
</div>
)}
- {props.isOpen && <div class="space-y-5 border-t border-gray-200 bg-gray-50/40 p-4 sm:p-5">{props.children}</div>}
+ {props.isOpen && (
+ <div class="space-y-5 border-t border-gray-200 bg-gray-50/40 p-4 sm:p-5">
+ {props.children}
+ </div>
+ )}
</section>
);
}
@@ -182,14 +207,30 @@ interface EditorActionsProps {
saveLabel?: string;
}
-function EditorActions({ pending, error, disabled, onCancel, saveLabel }: EditorActionsProps): VNode {
+function EditorActions({
+ pending,
+ error,
+ disabled,
+ onCancel,
+ saveLabel,
+}: EditorActionsProps): VNode {
const { t } = useTranslation();
return (
<div class="space-y-3 border-t border-gray-200 pt-4">
<ApiErrorBanner error={error} title={t`Could not save changes`} />
<div class="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
- <Button type="button" variant="secondary" onClick={onCancel} disabled={pending}>{t`Cancel`}</Button>
- <Button type="submit" variant="primary" isLoading={pending} disabled={disabled || pending}>
+ <Button
+ type="button"
+ variant="secondary"
+ onClick={onCancel}
+ disabled={pending}
+ >{t`Cancel`}</Button>
+ <Button
+ type="submit"
+ variant="primary"
+ isLoading={pending}
+ disabled={disabled || pending}
+ >
{saveLabel || t`Save changes`}
</Button>
</div>
@@ -200,6 +241,7 @@ function EditorActions({ pending, error, disabled, onCancel, saveLabel }: Editor
export function BusinessSettingsScreen({
settings,
settingsResource,
+ phoneRegex,
onSave,
onChangePassword,
passwordVerifierAvailable = false,
@@ -207,8 +249,12 @@ export function BusinessSettingsScreen({
}: BusinessSettingsScreenProps): VNode {
const { t } = useTranslation();
const incomingSettings = settings ?? settingsResource?.data;
- const [committed, setCommitted] = useState<BusinessSettings>(incomingSettings || DEFAULT_SETTINGS);
- const [editors, setEditors] = useState<Record<EditorSection, boolean>>(() => editorState(initialSection));
+ const [committed, setCommitted] = useState<BusinessSettings>(
+ incomingSettings || DEFAULT_SETTINGS,
+ );
+ const [editors, setEditors] = useState<Record<EditorSection, boolean>>(() =>
+ editorState(initialSection),
+ );
const editorsRef = useRef(editors);
const hasReceivedSettingsRef = useRef(Boolean(incomingSettings));
editorsRef.current = editors;
@@ -216,19 +262,37 @@ export function BusinessSettingsScreen({
const [name, setName] = useState(committed.name);
const [email, setEmail] = useState(committed.email || "");
const [phone, setPhone] = useState(committed.phone_number || "");
+ const isPhoneInvalid = phone !== "" && !isValidMfaPhone(phone, phoneRegex);
const [website, setWebsite] = useState(committed.website || "");
const [logoUrl, setLogoUrl] = useState(committed.logoUrl || "");
- const [address, setAddress] = useState<TalerMerchantApi.Location>(committed.addressLocation || {});
- const [jurisdiction, setJurisdiction] = useState<TalerMerchantApi.Location>(committed.jurisdictionLocation || {});
+ const [address, setAddress] = useState<TalerMerchantApi.Location>(
+ committed.addressLocation || {},
+ );
+ const [jurisdiction, setJurisdiction] = useState<TalerMerchantApi.Location>(
+ committed.jurisdictionLocation || {},
+ );
const [useStefan, setUseStefan] = useState(Boolean(committed.use_stefan));
- const [payDelayUs, setPayDelayUs] = useState(committed.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!);
- const [refundDelayUs, setRefundDelayUs] = useState(committed.defaultRefundDelayUs ?? 0);
- const [wireDelayUs, setWireDelayUs] = useState(committed.defaultWireTransferDelayUs ?? DEFAULT_SETTINGS.defaultWireTransferDelayUs!);
+ const [payDelayUs, setPayDelayUs] = useState(
+ committed.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!,
+ );
+ const [refundDelayUs, setRefundDelayUs] = useState(
+ committed.defaultRefundDelayUs ?? 0,
+ );
+ const [wireDelayUs, setWireDelayUs] = useState(
+ committed.defaultWireTransferDelayUs ??
+ DEFAULT_SETTINGS.defaultWireTransferDelayUs!,
+ );
const [delayValidity, setDelayValidity] = useState([true, true, true]);
- const [roundingInterval, setRoundingInterval] = useState(committed.defaultWireTransferRoundingInterval || "NONE");
+ const [roundingInterval, setRoundingInterval] = useState(
+ committed.defaultWireTransferRoundingInterval || "NONE",
+ );
- const [pendingSection, setPendingSection] = useState<EditorSection | null>(null);
- const [sectionErrors, setSectionErrors] = useState<Partial<Record<EditorSection, unknown>>>({});
+ const [pendingSection, setPendingSection] = useState<EditorSection | null>(
+ null,
+ );
+ const [sectionErrors, setSectionErrors] = useState<
+ Partial<Record<EditorSection, unknown>>
+ >({});
const [savedSection, setSavedSection] = useState<EditorSection | null>(null);
const [currentPassword, setCurrentPassword] = useState("");
@@ -252,9 +316,14 @@ export function BusinessSettingsScreen({
} else if (section === "fees") {
setUseStefan(Boolean(source.use_stefan));
} else if (section === "delays") {
- setPayDelayUs(source.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!);
+ setPayDelayUs(
+ source.defaultPayDelayUs ?? DEFAULT_SETTINGS.defaultPayDelayUs!,
+ );
setRefundDelayUs(source.defaultRefundDelayUs ?? 0);
- setWireDelayUs(source.defaultWireTransferDelayUs ?? DEFAULT_SETTINGS.defaultWireTransferDelayUs!);
+ setWireDelayUs(
+ source.defaultWireTransferDelayUs ??
+ DEFAULT_SETTINGS.defaultWireTransferDelayUs!,
+ );
setRoundingInterval(source.defaultWireTransferRoundingInterval || "NONE");
} else if (section === "security") {
setCurrentPassword("");
@@ -268,7 +337,8 @@ export function BusinessSettingsScreen({
const preserveOpenDrafts = hasReceivedSettingsRef.current;
setCommitted(incomingSettings);
for (const section of Object.keys(CLOSED_EDITORS) as EditorSection[]) {
- if (!preserveOpenDrafts || !editorsRef.current[section]) seedSection(section, incomingSettings);
+ if (!preserveOpenDrafts || !editorsRef.current[section])
+ seedSection(section, incomingSettings);
}
hasReceivedSettingsRef.current = true;
// The section seeders deliberately preserve every editor that is currently open.
@@ -293,7 +363,10 @@ export function BusinessSettingsScreen({
setEditors((current) => ({ ...current, [section]: false }));
};
- const saveSection = async (section: EditorSection, patch: Partial<BusinessSettings>) => {
+ const saveSection = async (
+ section: EditorSection,
+ patch: Partial<BusinessSettings>,
+ ) => {
const next: BusinessSettings = { ...committed, ...patch };
setPendingSection(section);
setSectionErrors((current) => ({ ...current, [section]: undefined }));
@@ -314,15 +387,26 @@ export function BusinessSettingsScreen({
event.preventDefault();
setSectionErrors((current) => ({ ...current, security: undefined }));
if (!newPassword) {
- setSectionErrors((current) => ({ ...current, security: new Error(t`Please enter a new password.`) }));
+ setSectionErrors((current) => ({
+ ...current,
+ security: new Error(t`Please enter a new password.`),
+ }));
return;
}
if (newPassword.length < 8) {
- setSectionErrors((current) => ({ ...current, security: new Error(t`New password must be at least 8 characters long.`) }));
+ setSectionErrors((current) => ({
+ ...current,
+ security: new Error(
+ t`New password must be at least 8 characters long.`,
+ ),
+ }));
return;
}
if (newPassword !== confirmPassword) {
- setSectionErrors((current) => ({ ...current, security: new Error(t`New passwords do not match.`) }));
+ setSectionErrors((current) => ({
+ ...current,
+ security: new Error(t`New passwords do not match.`),
+ }));
return;
}
if (passwordVerifierAvailable && !currentPassword) {
@@ -351,28 +435,61 @@ export function BusinessSettingsScreen({
}
};
- const pageHeader = <Header title={t`Merchant account`} subtitle={t`Manage your business profile, order defaults, and account security.`} />;
+ const pageHeader = (
+ <Header
+ title={t`Merchant account`}
+ subtitle={t`Manage your business profile, order defaults, and account security.`}
+ />
+ );
const hasRemoteData = Boolean(incomingSettings);
if (settingsResource?.isLoading && !hasRemoteData) {
- return <div class="max-w-4xl space-y-5">{pageHeader}<InitialLoadingState>{t`Loading merchant account settings…`}</InitialLoadingState></div>;
+ return (
+ <div class="max-w-4xl space-y-5">
+ {pageHeader}
+ <InitialLoadingState>{t`Loading merchant account settings…`}</InitialLoadingState>
+ </div>
+ );
}
if (settingsResource?.error && !hasRemoteData) {
- return <div class="max-w-4xl space-y-5">{pageHeader}<ReadErrorBanner resource={settingsResource} title={t`Merchant account settings could not be loaded`} /></div>;
+ return (
+ <div class="max-w-4xl space-y-5">
+ {pageHeader}
+ <ReadErrorBanner
+ resource={settingsResource}
+ title={t`Merchant account settings could not be loaded`}
+ />
+ </div>
+ );
}
const profileSummary = (
<span class="inline-flex items-center gap-2">
- <strong class="font-semibold text-gray-900">{committed.name || t`Business name required`}</strong>
+ <strong class="font-semibold text-gray-900">
+ {committed.name || t`Business name required`}
+ </strong>
{committedLogoStatus === "ready" ? (
- <img src={committed.logoUrl} alt={t`Business logo`} class="h-6 w-6 rounded border border-gray-200 bg-white object-contain p-0.5" />
+ <img
+ src={committed.logoUrl}
+ alt={t`Business logo`}
+ class="h-6 w-6 rounded border border-gray-200 bg-white object-contain p-0.5"
+ />
) : (
- <span> · {committedLogoStatus === "invalid" ? t`Logo needs attention` : committedLogoStatus === "checking" ? t`Checking logo…` : t`No logo`}</span>
+ <span>
+ {" "}
+ ·{" "}
+ {committedLogoStatus === "invalid"
+ ? t`Logo needs attention`
+ : committedLogoStatus === "checking"
+ ? t`Checking logo…`
+ : t`No logo`}
+ </span>
)}
</span>
);
- const contactSummary = committed.email || committed.website
- ? [committed.email, committed.website].filter(Boolean).join(" · ")
- : t`No public contact details configured`;
+ const contactSummary =
+ committed.email || committed.website
+ ? [committed.email, committed.website].filter(Boolean).join(" · ")
+ : t`No public contact details configured`;
const addressLines = formatStructuredAddressLines(committed.addressLocation);
const jurisdictionLines = formatStructuredAddressLines(
committed.jurisdictionLocation,
@@ -405,7 +522,12 @@ export function BusinessSettingsScreen({
return (
<div class="max-w-4xl space-y-6">
{pageHeader}
- {settingsResource?.error && <ReadErrorBanner resource={settingsResource} title={t`Merchant account settings could not be refreshed`} />}
+ {settingsResource?.error && (
+ <ReadErrorBanner
+ resource={settingsResource}
+ title={t`Merchant account settings could not be refreshed`}
+ />
+ )}
<div class="space-y-3">
<div class="px-1">
@@ -413,44 +535,164 @@ export function BusinessSettingsScreen({
<p class="mt-1 text-sm text-gray-600">{t`Information customers see during payment and on receipts.`}</p>
</div>
- <DisclosureSection title={t`Identity and logo`} subtitle={t`Your public business name and uploaded logo.`} summary={profileSummary} isOpen={editors.profile} saved={savedSection === "profile"} onOpen={() => openSection("profile")}>
- <form onSubmit={(event) => { event.preventDefault(); void saveSection("profile", { name, logoUrl }); }} class="max-w-2xl space-y-5">
+ <DisclosureSection
+ title={t`Identity and logo`}
+ subtitle={t`Your public business name and uploaded logo.`}
+ summary={profileSummary}
+ isOpen={editors.profile}
+ saved={savedSection === "profile"}
+ onOpen={() => openSection("profile")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ void saveSection("profile", { name, logoUrl });
+ }}
+ class="max-w-2xl space-y-5"
+ >
<div class="space-y-1">
- <label htmlFor="business-name" class="block text-xs font-semibold text-gray-700">{t`Business Name`} <span class="text-red-500">*</span></label>
- <input id="business-name" type="text" value={name} onInput={(event) => setName(event.currentTarget.value)} required class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
+ <label
+ htmlFor="business-name"
+ class="block text-xs font-semibold text-gray-700"
+ >
+ {t`Business Name`} <span class="text-red-500">*</span>
+ </label>
+ <input
+ id="business-name"
+ type="text"
+ value={name}
+ onInput={(event) => setName(event.currentTarget.value)}
+ required
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
</div>
- <ImageUploadInput id="business-logo" label={t`Logo`} helpText={t`Upload a PNG, JPEG, WebP, or SVG logo to display on customer receipts.`} value={logoUrl} onChange={setLogoUrl} />
- {draftLogoStatus === "invalid" && <p role="alert" class="text-sm font-semibold text-red-700">{t`Remove or replace the logo before saving this section.`}</p>}
- <EditorActions pending={pendingSection === "profile"} error={sectionErrors.profile} disabled={!name.trim() || draftLogoStatus === "checking" || draftLogoStatus === "invalid"} onCancel={() => cancelSection("profile")} />
+ <ImageUploadInput
+ id="business-logo"
+ label={t`Logo`}
+ helpText={t`Upload a PNG, JPEG, WebP, or SVG logo to display on customer receipts.`}
+ value={logoUrl}
+ onChange={setLogoUrl}
+ />
+ {draftLogoStatus === "invalid" && (
+ <p
+ role="alert"
+ class="text-sm font-semibold text-red-700"
+ >{t`Remove or replace the logo before saving this section.`}</p>
+ )}
+ <EditorActions
+ pending={pendingSection === "profile"}
+ error={sectionErrors.profile}
+ disabled={
+ !name.trim() ||
+ draftLogoStatus === "checking" ||
+ draftLogoStatus === "invalid"
+ }
+ onCancel={() => cancelSection("profile")}
+ />
</form>
</DisclosureSection>
- <DisclosureSection title={t`Customer contact`} subtitle={t`Public email address and business website.`} summary={contactSummary} isOpen={editors.contact} saved={savedSection === "contact"} onOpen={() => openSection("contact")}>
- <form onSubmit={(event) => { event.preventDefault(); void saveSection("contact", { email, website }); }} class="max-w-xl space-y-5">
+ <DisclosureSection
+ title={t`Customer contact`}
+ subtitle={t`Public email address and business website.`}
+ summary={contactSummary}
+ isOpen={editors.contact}
+ saved={savedSection === "contact"}
+ onOpen={() => openSection("contact")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ void saveSection("contact", { email, website });
+ }}
+ class="max-w-xl space-y-5"
+ >
<div class="space-y-1">
- <label htmlFor="business-email" class="block text-xs font-semibold text-gray-700">{t`Email Address`}</label>
- <input id="business-email" type="email" value={email} onInput={(event) => setEmail(event.currentTarget.value)} placeholder="contact@example.com" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
+ <label
+ htmlFor="business-email"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Email Address`}</label>
+ <input
+ id="business-email"
+ type="email"
+ value={email}
+ onInput={(event) => setEmail(event.currentTarget.value)}
+ placeholder="contact@example.com"
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
<p class="text-xs text-gray-600">{t`Shown to customers and used for email verification codes.`}</p>
</div>
<div class="space-y-1">
- <label htmlFor="business-website" class="block text-xs font-semibold text-gray-700">{t`Website URL`}</label>
- <input id="business-website" type="url" value={website} onInput={(event) => setWebsite(event.currentTarget.value)} placeholder="https://example.com" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" />
+ <label
+ htmlFor="business-website"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Website URL`}</label>
+ <input
+ id="business-website"
+ type="url"
+ value={website}
+ onInput={(event) => setWebsite(event.currentTarget.value)}
+ placeholder="https://example.com"
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ />
</div>
- <EditorActions pending={pendingSection === "contact"} error={sectionErrors.contact} onCancel={() => cancelSection("contact")} />
+ <EditorActions
+ pending={pendingSection === "contact"}
+ error={sectionErrors.contact}
+ onCancel={() => cancelSection("contact")}
+ />
</form>
</DisclosureSection>
- <DisclosureSection title={t`Business locations`} subtitle={t`Physical business address and legal jurisdiction.`} summary={addressSummary} isOpen={editors.address} saved={savedSection === "address"} onOpen={() => openSection("address")}>
- <form onSubmit={(event) => { event.preventDefault(); void saveSection("address", { addressLocation: address, jurisdictionLocation: jurisdiction }); }} class="space-y-6">
- <LocationInput idPrefix="physical-address" label={t`Physical business address`} subtitle={t`The registered location included in customer contracts.`} value={address} onChange={setAddress} />
+ <DisclosureSection
+ title={t`Business locations`}
+ subtitle={t`Physical business address and legal jurisdiction.`}
+ summary={addressSummary}
+ isOpen={editors.address}
+ saved={savedSection === "address"}
+ onOpen={() => openSection("address")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ void saveSection("address", {
+ addressLocation: address,
+ jurisdictionLocation: jurisdiction,
+ });
+ }}
+ class="space-y-6"
+ >
+ <LocationInput
+ idPrefix="physical-address"
+ label={t`Physical business address`}
+ subtitle={t`The registered location included in customer contracts.`}
+ value={address}
+ onChange={setAddress}
+ />
<div class="border-t border-gray-200 pt-5">
<div class="mb-4 flex flex-col items-start justify-between gap-3 sm:flex-row">
- <div><h4 class="text-sm font-bold text-gray-800">{t`Legal jurisdiction`}</h4><p class="mt-1 text-xs text-gray-600">{t`The location used for legal dispute resolution.`}</p></div>
- <Button type="button" variant="secondary" size="sm" onClick={() => setJurisdiction({ ...address })}>{t`Use physical address`}</Button>
+ <div>
+ <h4 class="text-sm font-bold text-gray-800">{t`Legal jurisdiction`}</h4>
+ <p class="mt-1 text-xs text-gray-600">{t`The location used for legal dispute resolution.`}</p>
+ </div>
+ <Button
+ type="button"
+ variant="secondary"
+ size="sm"
+ onClick={() => setJurisdiction({ ...address })}
+ >{t`Use physical address`}</Button>
</div>
- <LocationInput idPrefix="legal-jurisdiction" value={jurisdiction} onChange={setJurisdiction} />
+ <LocationInput
+ idPrefix="legal-jurisdiction"
+ value={jurisdiction}
+ onChange={setJurisdiction}
+ />
</div>
- <EditorActions pending={pendingSection === "address"} error={sectionErrors.address} onCancel={() => cancelSection("address")} />
+ <EditorActions
+ pending={pendingSection === "address"}
+ error={sectionErrors.address}
+ onCancel={() => cancelSection("address")}
+ />
</form>
</DisclosureSection>
</div>
@@ -461,42 +703,206 @@ export function BusinessSettingsScreen({
<p class="mt-1 text-sm text-gray-600">{t`Starting values for new orders unless an order overrides them.`}</p>
</div>
- <DisclosureSection title={t`Transaction fees`} subtitle={t`Choose whether the business or customer covers transaction costs.`} summary={committed.use_stefan ? t`Business covers transaction fees` : t`Transaction fees are added to the customer’s payment`} isOpen={editors.fees} saved={savedSection === "fees"} onOpen={() => openSection("fees")}>
- <form onSubmit={(event) => { event.preventDefault(); void saveSection("fees", { use_stefan: useStefan }); }} class="space-y-5">
+ <DisclosureSection
+ title={t`Transaction fees`}
+ subtitle={t`Choose whether the business or customer covers transaction costs.`}
+ summary={
+ committed.use_stefan
+ ? t`Business covers transaction fees`
+ : t`Transaction fees are added to the customer’s payment`
+ }
+ isOpen={editors.fees}
+ saved={savedSection === "fees"}
+ onOpen={() => openSection("fees")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ void saveSection("fees", { use_stefan: useStefan });
+ }}
+ class="space-y-5"
+ >
<div class="flex max-w-2xl items-start gap-3">
- <input id="use-stefan" type="checkbox" checked={useStefan} onChange={(event) => setUseStefan(event.currentTarget.checked)} class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-blue-500" />
- <div><label htmlFor="use-stefan" class="block cursor-pointer text-sm font-bold text-gray-900">{t`Cover transaction fees`}</label><p class="mt-1 text-sm text-gray-600">{t`The business pays the transaction cost instead of adding it to the customer’s payment.`}</p></div>
+ <input
+ id="use-stefan"
+ type="checkbox"
+ checked={useStefan}
+ onChange={(event) => setUseStefan(event.currentTarget.checked)}
+ class="mt-0.5 h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300 text-blue-600 focus:ring-blue-500"
+ />
+ <div>
+ <label
+ htmlFor="use-stefan"
+ class="block cursor-pointer text-sm font-bold text-gray-900"
+ >{t`Cover transaction fees`}</label>
+ <p class="mt-1 text-sm text-gray-600">{t`The business pays the transaction cost instead of adding it to the customer’s payment.`}</p>
+ </div>
</div>
- <EditorActions pending={pendingSection === "fees"} error={sectionErrors.fees} onCancel={() => cancelSection("fees")} />
+ <EditorActions
+ pending={pendingSection === "fees"}
+ error={sectionErrors.fees}
+ onCancel={() => cancelSection("fees")}
+ />
</form>
</DisclosureSection>
- <DisclosureSection title={t`Payment, refund, and payout timing`} subtitle={t`Default time limits for new orders and payouts.`} summary={delaySummary} isOpen={editors.delays} saved={savedSection === "delays"} onOpen={() => openSection("delays")}>
- <form onSubmit={(event) => { event.preventDefault(); if (delayValidity.some((valid) => !valid)) return; void saveSection("delays", { defaultPayDelayUs: payDelayUs, defaultRefundDelayUs: refundDelayUs, defaultWireTransferDelayUs: wireDelayUs, defaultWireTransferRoundingInterval: roundingInterval, defaultPayDeadlineHours: typeof payDelayUs === "number" ? Math.round(payDelayUs / 3_600_000_000) : undefined }); }} class="space-y-5">
+ <DisclosureSection
+ title={t`Payment, refund, and payout timing`}
+ subtitle={t`Default time limits for new orders and payouts.`}
+ summary={delaySummary}
+ isOpen={editors.delays}
+ saved={savedSection === "delays"}
+ onOpen={() => openSection("delays")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ if (delayValidity.some((valid) => !valid)) return;
+ void saveSection("delays", {
+ defaultPayDelayUs: payDelayUs,
+ defaultRefundDelayUs: refundDelayUs,
+ defaultWireTransferDelayUs: wireDelayUs,
+ defaultWireTransferRoundingInterval: roundingInterval,
+ defaultPayDeadlineHours:
+ typeof payDelayUs === "number"
+ ? Math.round(payDelayUs / 3_600_000_000)
+ : undefined,
+ });
+ }}
+ class="space-y-5"
+ >
<div class="grid grid-cols-1 gap-x-6 gap-y-5 sm:grid-cols-2">
- <DurationInput id="pay-delay" label={t`Payment window`} helpText={t`How long a customer has to pay before an unpaid order expires.`} valueUs={payDelayUs} onChangeUs={setPayDelayUs} onValidityChange={(valid) => setDelayValidity((all) => [valid, all[1]!, all[2]!])} required />
- <div><DurationInput id="refund-delay" label={t`Refund window`} helpText={t`How long you can issue a refund after payment.`} valueUs={refundDelayUs} onChangeUs={setRefundDelayUs} onValidityChange={(valid) => setDelayValidity((all) => [all[0]!, valid, all[2]!])} required />{refundDelayUs === 0 && <p role="status" class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs font-semibold text-amber-900">{t`A zero refund window prevents refunds after payment.`}</p>}</div>
- <DurationInput id="wire-delay" label={t`Payout delay`} helpText={t`How long the payment service may wait so it can combine several orders in one transfer.`} valueUs={wireDelayUs} onChangeUs={setWireDelayUs} onValidityChange={(valid) => setDelayValidity((all) => [all[0]!, all[1]!, valid])} required />
+ <DurationInput
+ id="pay-delay"
+ label={t`Payment window`}
+ helpText={t`How long a customer has to pay before an unpaid order expires.`}
+ valueUs={payDelayUs}
+ onChangeUs={setPayDelayUs}
+ onValidityChange={(valid) =>
+ setDelayValidity((all) => [valid, all[1]!, all[2]!])
+ }
+ required
+ />
+ <div>
+ <DurationInput
+ id="refund-delay"
+ label={t`Refund window`}
+ helpText={t`How long you can issue a refund after payment.`}
+ valueUs={refundDelayUs}
+ onChangeUs={setRefundDelayUs}
+ onValidityChange={(valid) =>
+ setDelayValidity((all) => [all[0]!, valid, all[2]!])
+ }
+ required
+ />
+ {refundDelayUs === 0 && (
+ <p
+ role="status"
+ class="mt-2 rounded-md border border-amber-200 bg-amber-50 p-2 text-xs font-semibold text-amber-900"
+ >{t`A zero refund window prevents refunds after payment.`}</p>
+ )}
+ </div>
+ <DurationInput
+ id="wire-delay"
+ label={t`Payout delay`}
+ helpText={t`How long the payment service may wait so it can combine several orders in one transfer.`}
+ valueUs={wireDelayUs}
+ onChangeUs={setWireDelayUs}
+ onValidityChange={(valid) =>
+ setDelayValidity((all) => [all[0]!, all[1]!, valid])
+ }
+ required
+ />
<div class="space-y-1">
- <label htmlFor="wire-rounding" class="block text-xs font-semibold text-gray-700">{t`Payout deadline rounding`}</label>
- <select id="wire-rounding" value={roundingInterval} onChange={(event) => setRoundingInterval(event.currentTarget.value)} class="w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500">
- <option value="NONE">{t`No rounding (exact time)`}</option><option value="SECOND">{t`Round to nearest second`}</option><option value="MINUTE">{t`Round to nearest minute`}</option><option value="HOUR">{t`Round to nearest hour`}</option><option value="DAY">{t`Round to end of day (midnight)`}</option><option value="WEEK">{t`Round to end of week`}</option><option value="MONTH">{t`Round to end of month`}</option><option value="QUARTER">{t`Round to end of quarter`}</option><option value="YEAR">{t`Round to end of year`}</option>
+ <label
+ htmlFor="wire-rounding"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Payout deadline rounding`}</label>
+ <select
+ id="wire-rounding"
+ value={roundingInterval}
+ onChange={(event) =>
+ setRoundingInterval(event.currentTarget.value)
+ }
+ class="w-full max-w-xs rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
+ >
+ <option value="NONE">{t`No rounding (exact time)`}</option>
+ <option value="SECOND">{t`Round to nearest second`}</option>
+ <option value="MINUTE">{t`Round to nearest minute`}</option>
+ <option value="HOUR">{t`Round to nearest hour`}</option>
+ <option value="DAY">{t`Round to end of day (midnight)`}</option>
+ <option value="WEEK">{t`Round to end of week`}</option>
+ <option value="MONTH">{t`Round to end of month`}</option>
+ <option value="QUARTER">{t`Round to end of quarter`}</option>
+ <option value="YEAR">{t`Round to end of year`}</option>
</select>
<p class="text-xs text-gray-600">{t`Aligns payout deadlines to the selected boundary; for example, day rounding uses midnight.`}</p>
</div>
</div>
- <EditorActions disabled={delayValidity.some((valid) => !valid)} pending={pendingSection === "delays"} error={sectionErrors.delays} onCancel={() => cancelSection("delays")} />
+ <EditorActions
+ disabled={delayValidity.some((valid) => !valid)}
+ pending={pendingSection === "delays"}
+ error={sectionErrors.delays}
+ onCancel={() => cancelSection("delays")}
+ />
</form>
</DisclosureSection>
</div>
<div class="space-y-3">
- <div class="px-1"><h2 class="text-lg font-bold text-gray-950">{t`Account security`}</h2><p class="mt-1 text-sm text-gray-600">{t`Verification contact and sign-in password for this merchant account.`}</p></div>
+ <div class="px-1">
+ <h2 class="text-lg font-bold text-gray-950">{t`Account security`}</h2>
+ <p class="mt-1 text-sm text-gray-600">{t`Verification contact and sign-in password for this merchant account.`}</p>
+ </div>
- <DisclosureSection title={t`Verification phone`} subtitle={t`Private mobile number used for administrative verification codes.`} summary={committed.phone_number || t`No verification phone configured`} isOpen={editors.phone} saved={savedSection === "phone"} onOpen={() => openSection("phone")}>
- <form onSubmit={(event) => { event.preventDefault(); void saveSection("phone", { phone_number: phone }); }} class="max-w-xl space-y-5">
- <div class="space-y-1"><label htmlFor="business-phone" class="block text-xs font-semibold text-gray-700">{t`Mobile Phone Number`}</label><input id="business-phone" type="tel" value={phone} onInput={(event) => setPhone(event.currentTarget.value)} placeholder="+1234567890" class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /><p class="text-xs text-gray-600">{t`Used for administrative SMS verification codes and never shown to customers.`}</p></div>
- <EditorActions pending={pendingSection === "phone"} error={sectionErrors.phone} onCancel={() => cancelSection("phone")} />
+ <DisclosureSection
+ title={t`Verification phone`}
+ subtitle={t`Private mobile number used for administrative verification codes.`}
+ summary={
+ committed.phone_number || t`No verification phone configured`
+ }
+ isOpen={editors.phone}
+ saved={savedSection === "phone"}
+ onOpen={() => openSection("phone")}
+ >
+ <form
+ onSubmit={(event) => {
+ event.preventDefault();
+ if (!isPhoneInvalid)
+ void saveSection("phone", { phone_number: phone });
+ }}
+ class="max-w-xl space-y-5"
+ >
+ <div class="space-y-1">
+ <label
+ htmlFor="business-phone"
+ class="block text-xs font-semibold text-gray-700"
+ >{t`Mobile Phone Number`}</label>
+ <input
+ id="business-phone"
+ type="tel"
+ value={phone}
+ onInput={(event) => setPhone(event.currentTarget.value)}
+ aria-invalid={isPhoneInvalid}
+ aria-describedby="business-phone-help"
+ placeholder="+1234567890"
+ class={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 ${isPhoneInvalid ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"}`}
+ />
+ <p
+ id="business-phone-help"
+ class={`text-xs ${isPhoneInvalid ? "font-semibold text-red-700" : "text-gray-600"}`}
+ >
+ {isPhoneInvalid
+ ? t`Invalid phone number`
+ : t`Used for administrative SMS verification codes and never shown to customers.`}
+ </p>
+ </div>
+ <EditorActions
+ pending={pendingSection === "phone"}
+ error={sectionErrors.phone}
+ disabled={isPhoneInvalid}
+ onCancel={() => cancelSection("phone")}
+ />
</form>
</DisclosureSection>
diff --git a/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx b/packages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx
@@ -31,9 +31,7 @@ import {
type LoginTokenInfo,
type PasswordVerifierV1,
} from "../stores/session.js";
-import {
- presetMerchantBaseUrl,
-} from "../stores/webuiConfig.js";
+import { presetMerchantBaseUrl } from "../stores/webuiConfig.js";
import { sanitizeTanCode, formatTanDigits } from "../utils/tan.js";
import { TanCodeInputGroup } from "../ui/TanCodeInputGroup.js";
import { PasswordInput } from "../ui/PasswordInput.js";
@@ -47,6 +45,7 @@ import {
solveRoundChallenge,
type ChallengeRoundState,
} from "../utils/challengeRounds.js";
+import { isValidMfaPhone } from "../utils/phone.js";
export { formatTanDigits };
/**
@@ -98,6 +97,7 @@ export interface ProvisionRequest {
export interface SelfProvisionScreenProps {
initialBackendUrl?: string;
mandatoryTanChannels?: ("email" | "sms")[];
+ phoneRegex?: string;
initialStep?: "form" | "select_channel" | "verify_email" | "verify_sms";
isEmbedded?: boolean;
onAccountCreated?: (data: {
@@ -123,7 +123,10 @@ export interface SelfProvisionScreenProps {
*/
onProvision?: (req: ProvisionRequest) => Promise<ProvisionAttempt>;
/** Send or resend a verification code; resolves to the cooldown in seconds. */
- onSendChallenge?: (challengeId: string, backendUrl: string) => Promise<number | "never">;
+ onSendChallenge?: (
+ challengeId: string,
+ backendUrl: string,
+ ) => Promise<number | "never">;
/** Check a verification code. */
onSolveChallenge?: (
challengeId: string,
@@ -142,11 +145,10 @@ function deriveUsernameSlug(text: string): string {
return text.toLowerCase().replace(/[^a-z0-9]/g, "");
}
-
-
export function SelfProvisionScreen({
initialBackendUrl = "https://backend.demo.taler.net/",
mandatoryTanChannels = [],
+ phoneRegex,
initialStep = "form",
isEmbedded = false,
onAccountCreated,
@@ -160,7 +162,11 @@ export function SelfProvisionScreen({
// Load saved draft from sessionStorage (Input Preservation per Spec 16 § 16.2)
const savedDraft = (() => {
try {
- if (!isEmbedded && typeof window !== "undefined" && window.sessionStorage) {
+ if (
+ !isEmbedded &&
+ typeof window !== "undefined" &&
+ window.sessionStorage
+ ) {
const raw = window.sessionStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
}
@@ -170,7 +176,9 @@ export function SelfProvisionScreen({
return null;
})();
- const [businessName, setBusinessName] = useState<string>(savedDraft?.businessName || "");
+ const [businessName, setBusinessName] = useState<string>(
+ savedDraft?.businessName || "",
+ );
const [username, setUsername] = useState<string>(savedDraft?.username || "");
const [isUsernameCustomized, setIsUsernameCustomized] = useState<boolean>(
savedDraft?.isUsernameCustomized || false,
@@ -179,8 +187,12 @@ export function SelfProvisionScreen({
const [confirmPassword, setConfirmPassword] = useState<string>("");
const [email, setEmail] = useState<string>(savedDraft?.email || "");
const [phone, setPhone] = useState<string>(savedDraft?.phone || "");
- const defaultUrl = initialBackendUrl || presetMerchantBaseUrl.value ||
- (typeof window !== "undefined" ? new URL("/", window.location.href).href : "http://localhost/");
+ const defaultUrl =
+ initialBackendUrl ||
+ presetMerchantBaseUrl.value ||
+ (typeof window !== "undefined"
+ ? new URL("/", window.location.href).href
+ : "http://localhost/");
const [backendUrl] = useState<string>(defaultUrl);
const [acceptedTerms, setAcceptedTerms] = useState<boolean>(false);
const [errorMsg, setErrorMsg] = useState<string>("");
@@ -254,6 +266,7 @@ export function SelfProvisionScreen({
const isEmailRequired = mandatoryTanChannels.includes("email");
const isSmsRequired = mandatoryTanChannels.includes("sms");
const isUsernameInvalid = username !== "" && !isValidInstanceId(username);
+ const isPhoneInvalid = phone !== "" && !isValidMfaPhone(phone, phoneRegex);
const [challengeRound, setChallengeRound] = useState<
ChallengeRoundState | undefined
@@ -293,9 +306,10 @@ export function SelfProvisionScreen({
setSmsCode("");
setIsTokenRound(tokenRound);
setErrorMsg("");
- setNoticeMsg(tokenRound
- ? t`Your account has been created. One last code confirms it is you signing in.`
- : "",
+ setNoticeMsg(
+ tokenRound
+ ? t`Your account has been created. One last code confirms it is you signing in.`
+ : "",
);
setResendCooldown(0);
if (next.step === "select") {
@@ -309,14 +323,22 @@ export function SelfProvisionScreen({
setErrorMsg(t`The server refused the registration. Please try again.`);
return;
}
- setStep(first.tan_channel === TalerMerchantApi.TanChannel.SMS
- ? "verify_sms"
- : "verify_email");
+ setStep(
+ first.tan_channel === TalerMerchantApi.TanChannel.SMS
+ ? "verify_sms"
+ : "verify_email",
+ );
await sendChallenge(first.challenge_id);
};
const handleResendTanCodes = async () => {
- if (resendCooldown === "never" || resendCooldown > 0 || submissionLock.current || !activeChallenge) return;
+ if (
+ resendCooldown === "never" ||
+ resendCooldown > 0 ||
+ submissionLock.current ||
+ !activeChallenge
+ )
+ return;
submissionLock.current = true;
setIsSubmitting(true);
setErrorMsg("");
@@ -328,7 +350,9 @@ export function SelfProvisionScreen({
}
};
- const completeAccountCreation = (resp: Extract<ProvisionAttempt, { type: "ok" }>) => {
+ const completeAccountCreation = (
+ resp: Extract<ProvisionAttempt, { type: "ok" }>,
+ ) => {
setChallengeRound(undefined);
setIsTokenRound(false);
setNoticeMsg("");
@@ -356,7 +380,9 @@ export function SelfProvisionScreen({
finalizing: boolean,
): Promise<void> => {
if (resp.type === "fail") {
- const respCase = (resp.failure?.httpStatus ?? resp.case) as HttpStatusCode | undefined;
+ const respCase = (resp.failure?.httpStatus ?? resp.case) as
+ | HttpStatusCode
+ | undefined;
setRawError(resp.failure ?? resp.detail ?? null);
if (respCase === HttpStatusCode.Accepted) {
const response = parseChallengeResponse(resp.detail);
@@ -366,9 +392,13 @@ export function SelfProvisionScreen({
}
}
if (!finalizing && respCase === HttpStatusCode.Conflict) {
- setErrorMsg(t`There is already another merchant account with this username.`);
+ setErrorMsg(
+ t`There is already another merchant account with this username.`,
+ );
} else if (!finalizing && respCase === HttpStatusCode.Unauthorized) {
- setErrorMsg(t`The server refused the registration request (401 Unauthorized).`);
+ setErrorMsg(
+ t`The server refused the registration request (401 Unauthorized).`,
+ );
} else if (respCase === undefined) {
setErrorMsg(t`Failed to connect to backend server.`);
} else if (finalizing) {
@@ -393,13 +423,16 @@ export function SelfProvisionScreen({
if (!challengeRound?.activeChallengeId) return;
const selectedRound = { ...challengeRound, step: "code" as const };
const selected = selectedRound.challenges.find(
- (challenge) => challenge.challenge_id === selectedRound.activeChallengeId,
+ (challenge) =>
+ challenge.challenge_id === selectedRound.activeChallengeId,
);
if (!selected) return;
setChallengeRound(selectedRound);
- setStep(selected.tan_channel === TalerMerchantApi.TanChannel.SMS
- ? "verify_sms"
- : "verify_email");
+ setStep(
+ selected.tan_channel === TalerMerchantApi.TanChannel.SMS
+ ? "verify_sms"
+ : "verify_email",
+ );
submissionLock.current = true;
setIsSubmitting(true);
try {
@@ -421,18 +454,31 @@ export function SelfProvisionScreen({
return;
}
if (isUsernameInvalid) {
- setErrorMsg(t`The merchant account identifier contains unsupported characters.`);
+ setErrorMsg(
+ t`The merchant account identifier contains unsupported characters.`,
+ );
return;
}
if (isEmailRequired && !email.trim()) {
- setErrorMsg(t`Email address is required for verification codes on this server.`);
+ setErrorMsg(
+ t`Email address is required for verification codes on this server.`,
+ );
return;
}
if (isSmsRequired && !phone.trim()) {
- setErrorMsg(t`Mobile phone number is required for SMS verification codes on this server.`);
+ setErrorMsg(
+ t`Mobile phone number is required for SMS verification codes on this server.`,
+ );
return;
}
- if (!devSettings.value.disablePasswordLengthCheck && password.length < 8) {
+ if (isPhoneInvalid) {
+ setErrorMsg(t`Invalid phone number`);
+ return;
+ }
+ if (
+ !devSettings.value.disablePasswordLengthCheck &&
+ password.length < 8
+ ) {
setErrorMsg(t`Password must be at least 8 characters long.`);
return;
}
@@ -498,7 +544,11 @@ export function SelfProvisionScreen({
try {
const confirmRes = onSolveChallenge
- ? await onSolveChallenge(activeChallenge.challenge_id, rawCode, backendUrl)
+ ? await onSolveChallenge(
+ activeChallenge.challenge_id,
+ rawCode,
+ backendUrl,
+ )
: { ok: false };
if (!confirmRes.ok) {
setErrorMsg(confirmRes.errorMsg || t`That code is not correct.`);
@@ -513,14 +563,18 @@ export function SelfProvisionScreen({
(challenge) => challenge.challenge_id === next.activeChallengeId,
);
if (!nextChallenge) {
- setErrorMsg(t`The server refused the registration. Please try again.`);
+ setErrorMsg(
+ t`The server refused the registration. Please try again.`,
+ );
return;
}
setEmailCode("");
setSmsCode("");
- setStep(nextChallenge.tan_channel === TalerMerchantApi.TanChannel.SMS
- ? "verify_sms"
- : "verify_email");
+ setStep(
+ nextChallenge.tan_channel === TalerMerchantApi.TanChannel.SMS
+ ? "verify_sms"
+ : "verify_email",
+ );
await sendChallenge(nextChallenge.challenge_id);
return;
}
@@ -559,17 +613,33 @@ export function SelfProvisionScreen({
})();
const content = (
- <div class={isEmbedded ? "w-full max-w-md mx-auto font-sans text-gray-900" : ""}>
+ <div
+ class={
+ isEmbedded ? "w-full max-w-md mx-auto font-sans text-gray-900" : ""
+ }
+ >
<div class="sm:mx-auto sm:w-full sm:max-w-md text-center">
- <TalerLogo class={isEmbedded ? "h-8 w-auto mx-auto text-taler-brand" : "h-10 w-auto mx-auto text-taler-brand"} />
- <h2 class={isEmbedded ? "mt-1 text-base font-bold text-gray-900" : "mt-2 text-xl font-extrabold text-gray-900"}>
+ <TalerLogo
+ class={
+ isEmbedded
+ ? "h-8 w-auto mx-auto text-taler-brand"
+ : "h-10 w-auto mx-auto text-taler-brand"
+ }
+ />
+ <h2
+ class={
+ isEmbedded
+ ? "mt-1 text-base font-bold text-gray-900"
+ : "mt-2 text-xl font-extrabold text-gray-900"
+ }
+ >
{step === "verify_email"
? t`Verify your email address`
: step === "verify_sms"
- ? t`Verify your phone number`
- : step === "select_channel"
- ? t`Confirm it is you`
- : t`Create your merchant account`}
+ ? t`Verify your phone number`
+ : step === "select_channel"
+ ? t`Confirm it is you`
+ : t`Create your merchant account`}
</h2>
<p class="mt-1 text-xs text-gray-600 flex items-center justify-center">
<BackendHostLink
@@ -580,28 +650,55 @@ export function SelfProvisionScreen({
</p>
</div>
- <ol aria-label={t`Account creation progress`} class="mx-auto mt-4 flex w-full max-w-md items-center gap-2 text-2xs font-semibold text-gray-500">
+ <ol
+ aria-label={t`Account creation progress`}
+ class="mx-auto mt-4 flex w-full max-w-md items-center gap-2 text-2xs font-semibold text-gray-500"
+ >
{[
{ id: "form", label: t`Account details` },
{ id: "select_channel", label: t`Verification method` },
{ id: "verify", label: t`Verification code` },
].map((item, index) => {
- const currentIndex = step === "form" ? 0 : step === "select_channel" ? 1 : 2;
+ const currentIndex =
+ step === "form" ? 0 : step === "select_channel" ? 1 : 2;
const active = index === currentIndex;
const complete = index < currentIndex;
return (
- <li key={item.id} aria-current={active ? "step" : undefined} class="flex flex-1 items-center gap-2">
- <span class={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
- active ? "bg-taler-brand text-white" : complete ? "bg-emerald-600 text-white" : "bg-gray-200 text-gray-600"
- }`}>{complete ? "✓" : index + 1}</span>
+ <li
+ key={item.id}
+ aria-current={active ? "step" : undefined}
+ class="flex flex-1 items-center gap-2"
+ >
+ <span
+ class={`flex h-6 w-6 shrink-0 items-center justify-center rounded-full ${
+ active
+ ? "bg-taler-brand text-white"
+ : complete
+ ? "bg-emerald-600 text-white"
+ : "bg-gray-200 text-gray-600"
+ }`}
+ >
+ {complete ? "✓" : index + 1}
+ </span>
<span class={active ? "text-gray-900" : ""}>{item.label}</span>
- {index < 2 && <span aria-hidden="true" class="ml-auto h-px flex-1 bg-gray-300" />}
+ {index < 2 && (
+ <span
+ aria-hidden="true"
+ class="ml-auto h-px flex-1 bg-gray-300"
+ />
+ )}
</li>
);
})}
</ol>
- <div class={isEmbedded ? "mt-4 sm:mx-auto sm:w-full sm:max-w-md" : "mt-6 sm:mx-auto sm:w-full sm:max-w-md"}>
+ <div
+ class={
+ isEmbedded
+ ? "mt-4 sm:mx-auto sm:w-full sm:max-w-md"
+ : "mt-6 sm:mx-auto sm:w-full sm:max-w-md"
+ }
+ >
<div class="bg-white py-6 px-4 shadow rounded-lg sm:px-8 border border-gray-200">
{noticeMsg && (
<div class="mb-4 p-3 bg-green-50 border border-green-200 text-green-900 text-xs font-medium rounded-md">
@@ -610,9 +707,17 @@ export function SelfProvisionScreen({
)}
{errorMsg && (
- <div data-error-banner role="alert" class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between">
+ <div
+ data-error-banner
+ role="alert"
+ class="mb-4 p-3 bg-red-50 border border-red-200 text-red-800 text-xs font-medium rounded-md flex items-center justify-between"
+ >
<div>⚠️ {errorMsg}</div>
- <CopyErrorButton textToCopy={errorMsg} errorDetail={rawError} className="ml-2 shrink-0" />
+ <CopyErrorButton
+ textToCopy={errorMsg}
+ errorDetail={rawError}
+ className="ml-2 shrink-0"
+ />
</div>
)}
@@ -620,14 +725,21 @@ export function SelfProvisionScreen({
{step === "form" && (
<>
<div>
- <label htmlFor="signup-business" class="block text-xs font-semibold text-gray-700 mb-1">
+ <label
+ htmlFor="signup-business"
+ class="block text-xs font-semibold text-gray-700 mb-1"
+ >
{t`Business Name`} <span class="text-red-500">*</span>
</label>
<input
id="signup-business"
type="text"
value={businessName}
- onInput={(e) => handleBusinessNameChange((e.target as HTMLInputElement).value)}
+ onInput={(e) =>
+ handleBusinessNameChange(
+ (e.target as HTMLInputElement).value,
+ )
+ }
required
placeholder="Acme Books & Coffee"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
@@ -638,8 +750,13 @@ export function SelfProvisionScreen({
</div>
<div>
- <label htmlFor="signup-username" class="block text-xs font-semibold text-gray-700 mb-1 flex items-center justify-between">
- <span>{t`Merchant account`} <span class="text-red-500">*</span></span>
+ <label
+ htmlFor="signup-username"
+ class="block text-xs font-semibold text-gray-700 mb-1 flex items-center justify-between"
+ >
+ <span>
+ {t`Merchant account`} <span class="text-red-500">*</span>
+ </span>
{isUsernameCustomized && (
<button
type="button"
@@ -657,14 +774,19 @@ export function SelfProvisionScreen({
id="signup-username"
type="text"
value={username}
- onInput={(e) => handleUsernameChange((e.target as HTMLInputElement).value)}
+ onInput={(e) =>
+ handleUsernameChange((e.target as HTMLInputElement).value)
+ }
aria-invalid={isUsernameInvalid}
aria-describedby="signup-username-help"
required
placeholder="acmebooks"
class={`w-full px-3 py-2 border rounded-md text-sm font-mono focus:ring-2 focus:outline-none bg-white ${isUsernameInvalid ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"}`}
/>
- <p id="signup-username-help" class={`mt-1 text-xs ${isUsernameInvalid ? "text-red-700 font-semibold" : "text-gray-500"}`}>
+ <p
+ id="signup-username-help"
+ class={`mt-1 text-xs ${isUsernameInvalid ? "text-red-700 font-semibold" : "text-gray-500"}`}
+ >
{isUsernameInvalid
? t`Use letters, numbers, hyphens, underscores, periods, or colons; “.” and “..” are not allowed.`
: t`This is the short identifier you will use to sign in. Uppercase letters are accepted and saved in lowercase.`}
@@ -673,14 +795,20 @@ export function SelfProvisionScreen({
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
- <label htmlFor="signup-email" class="block text-xs font-semibold text-gray-700 mb-1">
- {t`Email Address`} {isEmailRequired && <span class="text-red-500">*</span>}
+ <label
+ htmlFor="signup-email"
+ class="block text-xs font-semibold text-gray-700 mb-1"
+ >
+ {t`Email Address`}{" "}
+ {isEmailRequired && <span class="text-red-500">*</span>}
</label>
<input
id="signup-email"
type="email"
value={email}
- onInput={(e) => setEmail((e.target as HTMLInputElement).value)}
+ onInput={(e) =>
+ setEmail((e.target as HTMLInputElement).value)
+ }
required={isEmailRequired}
placeholder="merchant@example.com"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
@@ -691,27 +819,56 @@ export function SelfProvisionScreen({
</div>
<div>
- <label htmlFor="signup-phone" class="block text-xs font-semibold text-gray-700 mb-1">
- {t`Mobile Phone`} {isSmsRequired && <span class="text-red-500">*</span>}
+ <label
+ htmlFor="signup-phone"
+ class="block text-xs font-semibold text-gray-700 mb-1"
+ >
+ {t`Mobile Phone`}{" "}
+ {isSmsRequired && <span class="text-red-500">*</span>}
</label>
<input
id="signup-phone"
type="tel"
value={phone}
- onInput={(e) => setPhone((e.target as HTMLInputElement).value)}
+ onInput={(e) =>
+ setPhone((e.target as HTMLInputElement).value)
+ }
required={isSmsRequired}
+ aria-invalid={isPhoneInvalid}
+ aria-describedby="signup-phone-help"
placeholder="+41 79 123 45 67"
- class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm font-mono focus:ring-2 focus:ring-blue-500 focus:outline-none"
+ class={`w-full px-3 py-2 border rounded-md text-sm font-mono focus:ring-2 focus:outline-none ${isPhoneInvalid ? "border-red-500 focus:ring-red-500" : "border-gray-300 focus:ring-blue-500"}`}
/>
- <p class="mt-1 text-2xs text-gray-500">
- {t`For SMS codes.`}
+ <p
+ id="signup-phone-help"
+ class={`mt-1 text-2xs ${isPhoneInvalid ? "font-semibold text-red-700" : "text-gray-500"}`}
+ >
+ {isPhoneInvalid
+ ? t`Invalid phone number`
+ : t`For SMS codes.`}
</p>
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
- <PasswordInput id="signup-password" label={t`New Password`} value={password} onInput={setPassword} autoComplete="new-password" required minLength={8} />
- <PasswordInput id="signup-confirm-password" label={t`Repeat Password`} value={confirmPassword} onInput={setConfirmPassword} autoComplete="new-password" required minLength={8} />
+ <PasswordInput
+ id="signup-password"
+ label={t`New Password`}
+ value={password}
+ onInput={setPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
+ <PasswordInput
+ id="signup-confirm-password"
+ label={t`Repeat Password`}
+ value={confirmPassword}
+ onInput={setConfirmPassword}
+ autoComplete="new-password"
+ required
+ minLength={8}
+ />
</div>
<div class="pt-2">
@@ -719,7 +876,9 @@ export function SelfProvisionScreen({
<input
type="checkbox"
checked={acceptedTerms}
- onChange={(e) => setAcceptedTerms((e.target as HTMLInputElement).checked)}
+ onChange={(e) =>
+ setAcceptedTerms((e.target as HTMLInputElement).checked)
+ }
class="mt-0.5 rounded border-gray-300 text-taler-brand focus:ring-blue-500"
/>
<span>
@@ -731,7 +890,8 @@ export function SelfProvisionScreen({
class="text-taler-brand hover:underline font-semibold"
>
{t`Terms of Service`}
- </a>.
+ </a>
+ .
</span>
</label>
</div>
@@ -749,7 +909,8 @@ export function SelfProvisionScreen({
<label
key={challenge.challenge_id}
class={`flex items-center p-3 border rounded-lg cursor-pointer transition-colors ${
- challengeRound.activeChallengeId === challenge.challenge_id
+ challengeRound.activeChallengeId ===
+ challenge.challenge_id
? "bg-blue-50/50 border-blue-200 font-semibold"
: "hover:bg-gray-50 border-gray-200"
}`}
@@ -757,16 +918,26 @@ export function SelfProvisionScreen({
<input
type="radio"
name="signup_2fa_channel"
- checked={challengeRound.activeChallengeId === challenge.challenge_id}
- onChange={() => setChallengeRound(
- selectRoundChallenge(challengeRound, challenge.challenge_id),
- )}
+ checked={
+ challengeRound.activeChallengeId ===
+ challenge.challenge_id
+ }
+ onChange={() =>
+ setChallengeRound(
+ selectRoundChallenge(
+ challengeRound,
+ challenge.challenge_id,
+ ),
+ )
+ }
class="text-taler-brand focus:ring-blue-500"
/>
<span class="ml-2.5 font-medium text-gray-900">
- {challenge.tan_channel === TalerMerchantApi.TanChannel.EMAIL
+ {challenge.tan_channel ===
+ TalerMerchantApi.TanChannel.EMAIL
? t`Email`
- : t`Phone`}: {challenge.tan_info}
+ : t`Phone`}
+ : {challenge.tan_info}
</span>
</label>
))}
@@ -781,7 +952,9 @@ export function SelfProvisionScreen({
<h3 class="text-base font-bold text-gray-900">{t`Verify your email address`}</h3>
<p class="text-xs text-gray-600 mt-0.5">
{t`Email address`}:{" "}
- <strong class="font-bold text-gray-900">{email || "merchant@..."}</strong>
+ <strong class="font-bold text-gray-900">
+ {email || "merchant@..."}
+ </strong>
</p>
</div>
@@ -791,7 +964,10 @@ export function SelfProvisionScreen({
{/* Translators: Label for the protected operation that the
user is confirming with an authentication code. */}
<span>{t`Action being authorized:`} </span>
- <strong class="font-bold text-blue-900">{t`Creation of new merchant account`} ({username || businessName || "merchant"})</strong>
+ <strong class="font-bold text-blue-900">
+ {t`Creation of new merchant account`} (
+ {username || businessName || "merchant"})
+ </strong>
</div>
</div>
@@ -828,7 +1004,10 @@ export function SelfProvisionScreen({
{/* Translators: Label for the protected operation that the
user is confirming with an authentication code. */}
<span>{t`Action being authorized:`} </span>
- <strong class="font-bold text-blue-900">{t`Creation of new merchant account`} ({username || businessName || "merchant"})</strong>
+ <strong class="font-bold text-blue-900">
+ {t`Creation of new merchant account`} (
+ {username || businessName || "merchant"})
+ </strong>
</div>
</div>
@@ -856,10 +1035,10 @@ export function SelfProvisionScreen({
{isSubmitting
? t`Creating account...`
: step === "verify_email" || step === "select_channel"
- ? t`Continue`
- : step === "verify_sms"
- ? t`Complete setup`
- : t`Create merchant account`}
+ ? t`Continue`
+ : step === "verify_sms"
+ ? t`Complete setup`
+ : t`Create merchant account`}
</button>
{step !== "form" && (
@@ -878,7 +1057,10 @@ export function SelfProvisionScreen({
{step === "form" && (
<div class="text-center pt-2">
- <a href="#/signin" class="text-xs font-semibold text-taler-brand hover:underline">
+ <a
+ href="#/signin"
+ class="text-xs font-semibold text-taler-brand hover:underline"
+ >
{t`Already have an account? Sign in`}
</a>
</div>
diff --git a/packages/taler-merchant-webui/src/screens/screens.test.tsx b/packages/taler-merchant-webui/src/screens/screens.test.tsx
@@ -112,11 +112,7 @@ const TUTORIAL_MODULES = getTutorialModules(identityT);
import { MENU_ENTRIES, MENU_GROUPS } from "../ui/menuStructure.js";
import { Menu } from "../ui/Menu.js";
import { webUiConfig } from "../stores/webuiConfig.js";
-import {
- App,
- FORGOT_PASSWORD_MFA_ROUTE,
- isPublicPortalRoute,
-} from "../App.js";
+import { App, FORGOT_PASSWORD_MFA_ROUTE, isPublicPortalRoute } from "../App.js";
import { AdminAccountsScreen } from "./AdminAccountsScreen.js";
import { AdminAccountFormScreen } from "./AdminAccountFormScreen.js";
import { AdminAccountCredentialsScreen } from "./AdminAccountCredentialsScreen.js";
@@ -139,7 +135,10 @@ test("App component renders its bootstrap probe before choosing an auth flow", (
render(<App />, container);
- assert.match(container.textContent ?? "", /Checking whether this merchant server needs initial setup/);
+ assert.match(
+ container.textContent ?? "",
+ /Checking whether this merchant server needs initial setup/,
+ );
render(null, container); // unmount so effect cleanups run
document.body.removeChild(container);
@@ -234,8 +233,16 @@ test("SignInScreen solves every AND challenge and locks duplicate TAN submission
initialChallengeResponse={{
combi_and: true,
challenges: [
- { challenge_id: "EMAIL", tan_channel: TalerMerchantApi.TanChannel.EMAIL, tan_info: "m***" },
- { challenge_id: "SMS", tan_channel: TalerMerchantApi.TanChannel.SMS, tan_info: "1234" },
+ {
+ challenge_id: "EMAIL",
+ tan_channel: TalerMerchantApi.TanChannel.EMAIL,
+ tan_info: "m***",
+ },
+ {
+ challenge_id: "SMS",
+ tan_channel: TalerMerchantApi.TanChannel.SMS,
+ tan_info: "1234",
+ },
],
}}
onSendChallenge={async (challengeId) => {
@@ -246,31 +253,47 @@ test("SignInScreen solves every AND challenge and locks duplicate TAN submission
solved.push(challengeId);
return solved.length === 1 ? firstSolve : { ok: true };
}}
- onMfaSuccess={async (ids) => { completed = ids; }}
+ onMfaSuccess={async (ids) => {
+ completed = ids;
+ }}
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
const form = container.querySelector("form")!;
const enterCode = (value: string) => {
- const input = container.querySelector("#signin-2fa-code") as HTMLInputElement;
+ const input = container.querySelector(
+ "#signin-2fa-code",
+ ) as HTMLInputElement;
input.value = value;
input.dispatchEvent(new Event("input", { bubbles: true }));
};
- await act(async () => { enterCode("111111"); });
+ await act(async () => {
+ enterCode("111111");
+ });
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
- assert.deepEqual(solved, ["EMAIL"], "the synchronous lock blocks a duplicate solve");
+ assert.deepEqual(
+ solved,
+ ["EMAIL"],
+ "the synchronous lock blocks a duplicate solve",
+ );
await act(async () => {
releaseFirst({ ok: true });
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.deepEqual(sent, ["EMAIL", "SMS"]);
- await act(async () => { enterCode("222222"); });
+ await act(async () => {
+ enterCode("222222");
+ });
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.deepEqual(solved, ["EMAIL", "SMS"]);
assert.deepEqual(completed, ["EMAIL", "SMS"]);
@@ -288,8 +311,16 @@ test("SignInScreen completes an OR round with only the selected challenge", asyn
initialChallengeResponse={{
combi_and: false,
challenges: [
- { challenge_id: "EMAIL", tan_channel: TalerMerchantApi.TanChannel.EMAIL, tan_info: "m***" },
- { challenge_id: "SMS", tan_channel: TalerMerchantApi.TanChannel.SMS, tan_info: "1234" },
+ {
+ challenge_id: "EMAIL",
+ tan_channel: TalerMerchantApi.TanChannel.EMAIL,
+ tan_info: "m***",
+ },
+ {
+ challenge_id: "SMS",
+ tan_channel: TalerMerchantApi.TanChannel.SMS,
+ tan_info: "1234",
+ },
],
}}
onSendChallenge={async (challengeId) => {
@@ -297,24 +328,32 @@ test("SignInScreen completes an OR round with only the selected challenge", asyn
return 0;
}}
onSolveChallenge={async () => ({ ok: true })}
- onMfaSuccess={async (ids) => { completed = ids; }}
+ onMfaSuccess={async (ids) => {
+ completed = ids;
+ }}
/>,
container,
);
- const radios = container.querySelectorAll<HTMLInputElement>('input[name="2fa_channel"]');
+ const radios = container.querySelectorAll<HTMLInputElement>(
+ 'input[name="2fa_channel"]',
+ );
await act(async () => {
radios[1]!.dispatchEvent(new Event("change", { bubbles: true }));
});
const form = container.querySelector("form")!;
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
const input = container.querySelector("#signin-2fa-code") as HTMLInputElement;
await act(async () => {
input.value = "123456";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.deepEqual(sent, ["SMS"]);
assert.deepEqual(completed, ["SMS"]);
@@ -326,8 +365,74 @@ test("SelfProvisionScreen requires only configured signup channels", () => {
const container = document.createElement("div");
document.body.appendChild(container);
render(<SelfProvisionScreen mandatoryTanChannels={["sms"]} />, container);
- assert.equal((container.querySelector("#signup-email") as HTMLInputElement).required, false);
- assert.equal((container.querySelector("#signup-phone") as HTMLInputElement).required, true);
+ assert.equal(
+ (container.querySelector("#signup-email") as HTMLInputElement).required,
+ false,
+ );
+ assert.equal(
+ (container.querySelector("#signup-phone") as HTMLInputElement).required,
+ true,
+ );
+ render(null, container);
+ document.body.removeChild(container);
+});
+
+test("SelfProvisionScreen enforces the backend phone policy before provisioning", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let attempts = 0;
+ render(
+ <SelfProvisionScreen
+ mandatoryTanChannels={["sms"]}
+ phoneRegex={String.raw`^\+41 ?7[05-9]( ?[0-9]{3})( ?[0-9]{2})( ?[0-9]{2})$`}
+ onProvision={async () => {
+ attempts += 1;
+ return { type: "ok" };
+ }}
+ />,
+ container,
+ );
+
+ const enter = (selector: string, value: string) => {
+ const input = container.querySelector(selector) as HTMLInputElement;
+ input.value = value;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ };
+ await act(async () => {
+ enter("#signup-business", "Example Shop");
+ enter("#signup-phone", "+41 74 123 45 67");
+ enter("#signup-password", "correct horse");
+ enter("#signup-confirm-password", "correct horse");
+ const terms = container.querySelector(
+ 'input[type="checkbox"]',
+ ) as HTMLInputElement;
+ terms.checked = true;
+ terms.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+
+ const form = container.querySelector("form")!;
+ await act(async () => {
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(attempts, 0);
+ assert.strictEqual(
+ container.querySelector("#signup-phone")?.getAttribute("aria-invalid"),
+ "true",
+ );
+ assert.match(container.textContent ?? "", /Invalid phone number/);
+
+ await act(async () => enter("#signup-phone", "+41 79 123 45 67"));
+ await act(async () => {
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(attempts, 1);
+
render(null, container);
document.body.removeChild(container);
});
@@ -501,15 +606,21 @@ test("OrderListScreen uses a lookahead row without rendering or paging past an e
session.value = { account: "default", token: "secret-token:test" as never };
const container = document.createElement("div");
document.body.appendChild(container);
- const queries: Array<{ delta: number; status: string; search: string; offset?: string }> = [];
- const makeOrders = (count: number) => Array.from({ length: count }, (_, index) => ({
- orderId: `order-${index + 1}`,
- rowId: `row-${index + 1}`,
- summary: `Order ${index + 1}`,
- amount: "CHF:1",
- status: "paid" as const,
- createdAt: "2026-08-19 12:00",
- }));
+ const queries: Array<{
+ delta: number;
+ status: string;
+ search: string;
+ offset?: string;
+ }> = [];
+ const makeOrders = (count: number) =>
+ Array.from({ length: count }, (_, index) => ({
+ orderId: `order-${index + 1}`,
+ rowId: `row-${index + 1}`,
+ summary: `Order ${index + 1}`,
+ amount: "CHF:1",
+ status: "paid" as const,
+ createdAt: "2026-08-19 12:00",
+ }));
try {
render(
@@ -519,7 +630,9 @@ test("OrderListScreen uses a lookahead row without rendering or paging past an e
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.deepStrictEqual(queries.at(-1), {
delta: 21,
status: "all",
@@ -535,7 +648,9 @@ test("OrderListScreen uses a lookahead row without rendering or paging past an e
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(findButton(container, "Next")?.disabled, true);
render(
@@ -545,7 +660,9 @@ test("OrderListScreen uses a lookahead row without rendering or paging past an e
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(findButton(container, "Next")?.disabled, false);
assert.doesNotMatch(container.textContent ?? "", /order-21/);
@@ -760,16 +877,21 @@ test("MoneyInScreen shows provider-specific swapped onboarding stages", () => {
render(
<MoneyInScreen
accounts={[account]}
- sampleKycData={[{
- ...provider,
- status: "kyc-required",
- kyc_swap_tos_acceptance: undefined,
- tos_accepted_early: "v1",
- }]}
+ sampleKycData={[
+ {
+ ...provider,
+ status: "kyc-required",
+ kyc_swap_tos_acceptance: undefined,
+ tos_accepted_early: "v1",
+ },
+ ]}
/>,
container,
);
- assert.match(progressText(), /Accept terms.*Account validation.*More information.*Ready/);
+ assert.match(
+ progressText(),
+ /Accept terms.*Account validation.*More information.*Ready/,
+ );
assert.match(
container.querySelector('[aria-current="step"]')?.textContent ?? "",
/More information/,
@@ -778,11 +900,13 @@ test("MoneyInScreen shows provider-specific swapped onboarding stages", () => {
render(
<MoneyInScreen
accounts={[{ ...account, status: "ready" }]}
- sampleKycData={[{
- ...provider,
- status: "ready",
- kyc_swap_tos_acceptance: undefined,
- }]}
+ sampleKycData={[
+ {
+ ...provider,
+ status: "ready",
+ kyc_swap_tos_acceptance: undefined,
+ },
+ ]}
/>,
container,
);
@@ -797,7 +921,14 @@ test("BootstrapInstanceScreen renders the unauthenticated admin setup form", ()
const container = document.createElement("div");
document.body.appendChild(container);
- render(<BootstrapInstanceScreen backendUrl="https://merchant.example/" onChangeServer={() => undefined} onSubmit={async () => undefined} />, container);
+ render(
+ <BootstrapInstanceScreen
+ backendUrl="https://merchant.example/"
+ onChangeServer={() => undefined}
+ onSubmit={async () => undefined}
+ />,
+ container,
+ );
assert.match(container.innerHTML, /Set up this merchant server/);
assert.equal(
@@ -857,18 +988,26 @@ test("GuidedSetupScreen does not claim that a broken configured logo was added",
test("GuidedSetupScreen keeps next steps after required setup", () => {
const container = document.createElement("div");
document.body.appendChild(container);
- const accounts = [{
- id: "ready",
- account: "payto://iban/CH4431999123000889012?receiver-name=ACME",
- owner: "ACME Coffee",
- currency: "CHF",
- status: "ready" as const,
- arriving: "CHF:0",
- }];
+ const accounts = [
+ {
+ id: "ready",
+ account: "payto://iban/CH4431999123000889012?receiver-name=ACME",
+ owner: "ACME Coffee",
+ currency: "CHF",
+ status: "ready" as const,
+ arriving: "CHF:0",
+ },
+ ];
- render(<GuidedSetupScreen businessName="ACME Coffee" accounts={accounts} />, container);
+ render(
+ <GuidedSetupScreen businessName="ACME Coffee" accounts={accounts} />,
+ container,
+ );
assert.match(container.textContent ?? "", /Take your first payment/);
- assert.match(container.textContent ?? "", /Create a printable payment template/);
+ assert.match(
+ container.textContent ?? "",
+ /Create a printable payment template/,
+ );
assert.match(container.textContent ?? "", /Create a one-off order/);
render(
@@ -881,7 +1020,10 @@ test("GuidedSetupScreen keeps next steps after required setup", () => {
container,
);
assert.match(container.textContent ?? "", /Take your first payment/);
- assert.match(container.textContent ?? "", /Create a printable payment template/);
+ assert.match(
+ container.textContent ?? "",
+ /Create a printable payment template/,
+ );
assert.match(container.textContent ?? "", /Create a one-off order/);
render(null, container);
document.body.removeChild(container);
@@ -966,7 +1108,11 @@ test("AddPayoutAccountScreen enforces configured payment-target types and regex"
holder.dispatchEvent(new Event("input", { bubbles: true }));
});
assert.match(container.textContent ?? "", /payment-target policy/);
- assert.equal((container.querySelector('button[type="submit"]') as HTMLButtonElement).disabled, true);
+ assert.equal(
+ (container.querySelector('button[type="submit"]') as HTMLButtonElement)
+ .disabled,
+ true,
+ );
render(null, container);
document.body.removeChild(container);
});
@@ -986,16 +1132,21 @@ test("AddPayoutAccountScreen treats the wildcard payment-target regex as unrestr
const host = container.querySelector("#bank-host") as HTMLInputElement;
host.value = "bank.example.test";
host.dispatchEvent(new Event("input", { bubbles: true }));
- const account = container.querySelector("#account-name") as HTMLInputElement;
+ const account = container.querySelector(
+ "#account-name",
+ ) as HTMLInputElement;
account.value = "merchant";
account.dispatchEvent(new Event("input", { bubbles: true }));
- const holder = container.querySelector("#account-holder") as HTMLInputElement;
+ const holder = container.querySelector(
+ "#account-holder",
+ ) as HTMLInputElement;
holder.value = "Merchant";
holder.dispatchEvent(new Event("input", { bubbles: true }));
});
assert.strictEqual(
- (container.querySelector('button[type="submit"]') as HTMLButtonElement).disabled,
+ (container.querySelector('button[type="submit"]') as HTMLButtonElement)
+ .disabled,
false,
);
render(null, container);
@@ -1076,22 +1227,26 @@ const DETAIL_CHOICE_FIXTURES = [
description: "Standard price",
descriptionI18n: { de: "Standardpreis" },
inputs: [],
- outputs: [{
- type: "token" as const,
- tokenFamilySlug: "club",
- tokenFamilyName: "Beverage club",
- count: 1,
- }],
+ outputs: [
+ {
+ type: "token" as const,
+ tokenFamilySlug: "club",
+ tokenFamilyName: "Beverage club",
+ count: 1,
+ },
+ ],
},
{
amount: "CHF:0.00",
description: "Member reward",
- inputs: [{
- type: "token" as const,
- tokenFamilySlug: "club",
- tokenFamilyName: "Beverage club",
- count: 2,
- }],
+ inputs: [
+ {
+ type: "token" as const,
+ tokenFamilySlug: "club",
+ tokenFamilyName: "Beverage club",
+ count: 2,
+ },
+ ],
outputs: [{ type: "tax-receipt" as const }],
},
];
@@ -1101,7 +1256,9 @@ const DETAIL_CHOICE_BASE = {
summary: "Choice order",
status: "offered" as const,
createdAt: AbsoluteTime.fromMilliseconds(Date.parse("2026-08-05T10:00:00Z")),
- payDeadline: AbsoluteTime.fromMilliseconds(Date.parse("2026-08-05T18:00:00Z")),
+ payDeadline: AbsoluteTime.fromMilliseconds(
+ Date.parse("2026-08-05T18:00:00Z"),
+ ),
payUrl: "taler://pay/example.com/2026.217-V1TEST",
paymentChoices: DETAIL_CHOICE_FIXTURES,
};
@@ -1127,12 +1284,17 @@ test("OrderDetailScreen shows pending v1 choices and their effects before the QR
assert.match(text, /tax receipt for the full payment amount/);
assert.doesNotMatch(text, /Order total\s*CHF\s*0(?:\.00)?/);
- const choices = Array.from(container.querySelectorAll("section")).find((section) =>
- section.textContent?.includes("Payment choices"));
- const qr = container.querySelector("[data-taler-qr-code]") ?? container.querySelector("svg");
+ const choices = Array.from(container.querySelectorAll("section")).find(
+ (section) => section.textContent?.includes("Payment choices"),
+ );
+ const qr =
+ container.querySelector("[data-taler-qr-code]") ??
+ container.querySelector("svg");
assert.ok(choices);
assert.ok(qr);
- assert.ok(choices.compareDocumentPosition(qr) & Node.DOCUMENT_POSITION_FOLLOWING);
+ assert.ok(
+ choices.compareDocumentPosition(qr) & Node.DOCUMENT_POSITION_FOLLOWING,
+ );
render(null, container);
document.body.removeChild(container);
@@ -1147,7 +1309,9 @@ test("OrderDetailScreen shows only the selected choice after v1 payment", () =>
...DETAIL_CHOICE_BASE,
amount: "CHF:0.00",
status: "paid",
- paidAt: AbsoluteTime.fromMilliseconds(Date.parse("2026-08-05T12:00:00Z")),
+ paidAt: AbsoluteTime.fromMilliseconds(
+ Date.parse("2026-08-05T12:00:00Z"),
+ ),
selectedChoiceIndex: 1,
}}
/>,
@@ -1286,14 +1450,25 @@ test("OrderRefundScreen disables refund when order is 100% refunded", () => {
test("OrderRefundScreen presets use the exact remaining refund", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
- await act(async () => render(<OrderRefundScreen order={{
- orderId: "partial",
- summary: "Partial refund",
- amount: "CHF:10.00",
- status: "refunded",
- createdAt: AbsoluteTime.fromMilliseconds(1_700_000_000_000),
- refundStatus: { issuedAmount: "CHF:3.00", claimedAmount: "CHF:3.00", state: "claimed" },
- }} />, container));
+ await act(async () =>
+ render(
+ <OrderRefundScreen
+ order={{
+ orderId: "partial",
+ summary: "Partial refund",
+ amount: "CHF:10.00",
+ status: "refunded",
+ createdAt: AbsoluteTime.fromMilliseconds(1_700_000_000_000),
+ refundStatus: {
+ issuedAmount: "CHF:3.00",
+ claimedAmount: "CHF:3.00",
+ state: "claimed",
+ },
+ }}
+ />,
+ container,
+ ),
+ );
const refundAmount = () =>
(container.querySelector("#refund-amount-input") as HTMLInputElement).value;
assert.equal(refundAmount(), "7");
@@ -1332,11 +1507,11 @@ test("BusinessSettingsScreen summarizes completed details and opens the requeste
};
render(<BusinessSettingsScreen settings={settings} />, container);
+ assert.match(container.textContent ?? "", /ACME Coffee · No logo/);
assert.match(
container.textContent ?? "",
- /ACME Coffee · No logo/,
+ /Customer contacthello@example.com/,
);
- assert.match(container.textContent ?? "", /Customer contacthello@example.com/);
assert.equal(container.querySelector("#business-name"), null);
assert.equal(container.querySelector("#use-stefan"), null);
@@ -1403,7 +1578,9 @@ test("BusinessSettingsScreen cancels drafts and excludes another editor's dirty
fees.dispatchEvent(new Event("change", { bubbles: true }));
});
await act(async () => {
- fees.closest("form")?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ fees
+ .closest("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -1415,7 +1592,8 @@ test("BusinessSettingsScreen cancels drafts and excludes another editor's dirty
act(() => {
const profileForm = name.closest("form");
[...(profileForm?.querySelectorAll('button[type="button"]') ?? [])]
- .find((button) => button.textContent?.includes("Cancel"))?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ .find((button) => button.textContent?.includes("Cancel"))
+ ?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
assert.equal(container.querySelector("#business-name"), null);
assert.match(container.textContent ?? "", /ACME Coffee · No logo/);
@@ -1424,6 +1602,59 @@ test("BusinessSettingsScreen cancels drafts and excludes another editor's dirty
document.body.removeChild(container);
});
+test("BusinessSettingsScreen validates the private verification phone", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let saves = 0;
+ render(
+ <BusinessSettingsScreen
+ settings={{ name: "ACME Coffee", phone_number: "+41791234567" }}
+ phoneRegex={String.raw`^\+4179[0-9]+$`}
+ onSave={async () => {
+ saves += 1;
+ return "saved";
+ }}
+ />,
+ container,
+ );
+
+ act(() => findButton(container, "Verification phone")?.click());
+ const input = container.querySelector("#business-phone") as HTMLInputElement;
+ await act(async () => {
+ input.value = "+41 78 123 45 67";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ assert.strictEqual(input.getAttribute("aria-invalid"), "true");
+ assert.strictEqual(
+ (findButton(input.closest("form")!, "Save changes") as HTMLButtonElement)
+ .disabled,
+ true,
+ );
+ input
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ assert.strictEqual(saves, 0);
+
+ await act(async () => {
+ input.value = "+41 79 123 45 67";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const validInput = container.querySelector(
+ "#business-phone",
+ ) as HTMLInputElement;
+ assert.strictEqual(validInput.getAttribute("aria-invalid"), "false");
+ await act(async () => {
+ validInput
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(saves, 1);
+
+ render(null, container);
+ document.body.removeChild(container);
+});
+
test("BusinessSettingsScreen keeps a failed section open and reports success only after resolution", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -1442,7 +1673,9 @@ test("BusinessSettingsScreen keeps a failed section open and reports success onl
const email = container.querySelector("#business-email") as HTMLInputElement;
const form = email.closest("form")!;
await act(async () => {
- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.ok(container.querySelector("#business-email"));
@@ -1451,7 +1684,9 @@ test("BusinessSettingsScreen keeps a failed section open and reports success onl
rejectSave = false;
await act(async () => {
- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(container.querySelector("#business-email"), null);
@@ -1487,9 +1722,14 @@ test("BusinessSettingsScreen keeps an MFA-challenged save open and disables it w
const form = emailInput.closest("form");
assert.ok(form);
act(() => {
- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
});
- assert.strictEqual((findButton(container, "Save changes") as HTMLButtonElement).disabled, true);
+ assert.strictEqual(
+ (findButton(container, "Save changes") as HTMLButtonElement).disabled,
+ true,
+ );
await act(async () => {
finishChallenge?.("challenge");
@@ -1499,7 +1739,9 @@ test("BusinessSettingsScreen keeps an MFA-challenged save open and disables it w
assert.doesNotMatch(container.textContent ?? "", /Changes saved/);
await act(async () => {
- form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ form.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(container.querySelector("#business-email"), null);
@@ -1597,23 +1839,63 @@ test("BusinessSettingsScreen distinguishes initial loading, initial failure, and
const container = document.createElement("div");
document.body.appendChild(container);
const refresh = async () => undefined;
- render(<BusinessSettingsScreen settingsResource={{ data: undefined, error: undefined, isLoading: true, isRefreshing: false, refresh }} />, container);
- assert.match(container.textContent ?? "", /Loading merchant account settings/);
+ render(
+ <BusinessSettingsScreen
+ settingsResource={{
+ data: undefined,
+ error: undefined,
+ isLoading: true,
+ isRefreshing: false,
+ refresh,
+ }}
+ />,
+ container,
+ );
+ assert.match(
+ container.textContent ?? "",
+ /Loading merchant account settings/,
+ );
assert.equal(container.querySelector("#business-name"), null);
const failure = new Error("settings unavailable") as never;
- render(<BusinessSettingsScreen settingsResource={{ data: undefined, error: failure, isLoading: false, isRefreshing: false, refresh }} />, container);
+ render(
+ <BusinessSettingsScreen
+ settingsResource={{
+ data: undefined,
+ error: failure,
+ isLoading: false,
+ isRefreshing: false,
+ refresh,
+ }}
+ />,
+ container,
+ );
assert.match(container.textContent ?? "", /settings unavailable/);
assert.equal(container.querySelector("#business-name"), null);
const stale = { name: "Still visible" };
await act(async () => {
- render(<BusinessSettingsScreen settings={stale} settingsResource={{ data: stale, error: failure, isLoading: false, isRefreshing: false, refresh }} />, container);
+ render(
+ <BusinessSettingsScreen
+ settings={stale}
+ settingsResource={{
+ data: stale,
+ error: failure,
+ isLoading: false,
+ isRefreshing: false,
+ refresh,
+ }}
+ />,
+ container,
+ );
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(container.querySelector("#business-name"), null);
act(() => findButton(container, "Identity and logo")?.click());
- assert.strictEqual((container.querySelector("#business-name") as HTMLInputElement).value, "Still visible");
+ assert.strictEqual(
+ (container.querySelector("#business-name") as HTMLInputElement).value,
+ "Still visible",
+ );
assert.match(container.textContent ?? "", /settings unavailable/);
render(null, container);
@@ -1739,18 +2021,42 @@ test("InventoryScreen awaits quick price updates and sends a price patch", async
const container = document.createElement("div");
document.body.appendChild(container);
const patches: unknown[] = [];
- render(<InventoryScreen products={[{
- id: "coffee", name: "Coffee", price: "CHF:2.50", stock: "", stockTracked: false,
- soldCount: undefined, category: "",
- }]} categories={[]} onUpdateProduct={async (_id, patch) => { patches.push(patch); }} />, container);
- act(() => (container.querySelector('[title="Quick edit price"]') as HTMLButtonElement).click());
- const input = document.body.querySelector("#quick_price_input") as HTMLInputElement;
+ render(
+ <InventoryScreen
+ products={[
+ {
+ id: "coffee",
+ name: "Coffee",
+ price: "CHF:2.50",
+ stock: "",
+ stockTracked: false,
+ soldCount: undefined,
+ category: "",
+ },
+ ]}
+ categories={[]}
+ onUpdateProduct={async (_id, patch) => {
+ patches.push(patch);
+ }}
+ />,
+ container,
+ );
+ act(() =>
+ (
+ container.querySelector('[title="Quick edit price"]') as HTMLButtonElement
+ ).click(),
+ );
+ const input = document.body.querySelector(
+ "#quick_price_input",
+ ) as HTMLInputElement;
act(() => {
input.value = "3.75";
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- (input.closest("form") as HTMLFormElement).dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ (input.closest("form") as HTMLFormElement).dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
});
assert.deepEqual(patches, [{ price: "CHF:3.75" }]);
assert.equal(document.body.querySelector("#quick_price_input"), null);
@@ -1908,7 +2214,10 @@ test("AccessScreen renders the access it is given", () => {
assert.match(container.innerHTML, /Access for machines/);
assert.match(container.innerHTML, /Front Counter Till #1/);
assert.match(container.textContent ?? "", /ID: tok_till_01/);
- assert.strictEqual(container.querySelectorAll('input[type="search"]').length, 0);
+ assert.strictEqual(
+ container.querySelectorAll('input[type="search"]').length,
+ 0,
+ );
render(null, container); // unmount so effect cleanups run
document.body.removeChild(container);
@@ -1920,8 +2229,20 @@ test("AccessScreen pages by token ID and reloads the current page", async () =>
const queries: Array<{ limit: number; offset?: string }> = [];
let reloads = 0;
const tokens = [
- { id: "42", name: "Counter till", canDoText: "Take payments", createdAt: "2026-08-01 10:00", expiresText: "2026-09-01 10:00" },
- { id: "41", name: "Web shop", canDoText: "Take payments", createdAt: "2026-08-01 09:00", expiresText: "2026-09-01 09:00" },
+ {
+ id: "42",
+ name: "Counter till",
+ canDoText: "Take payments",
+ createdAt: "2026-08-01 10:00",
+ expiresText: "2026-09-01 10:00",
+ },
+ {
+ id: "41",
+ name: "Web shop",
+ canDoText: "Take payments",
+ createdAt: "2026-08-01 09:00",
+ expiresText: "2026-09-01 09:00",
+ },
];
render(
@@ -1929,12 +2250,16 @@ test("AccessScreen pages by token ID and reloads the current page", async () =>
tokens={tokens}
hasNextPage
lastUpdated={new Date()}
- onRefresh={() => { reloads += 1; }}
+ onRefresh={() => {
+ reloads += 1;
+ }}
onQueryChange={(query) => queries.push(query)}
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.deepStrictEqual(queries.at(-1), { limit: 20, offset: undefined });
assert.match(container.textContent ?? "", /ID: 42/);
@@ -1976,7 +2301,9 @@ test("ReloadControl reports and disables an in-progress reload", () => {
render(
<ReloadControl
- onReload={() => { reloads += 1; }}
+ onReload={() => {
+ reloads += 1;
+ }}
isReloading={false}
lastUpdated={new Date()}
/>,
@@ -1988,7 +2315,9 @@ test("ReloadControl reports and disables an in-progress reload", () => {
render(
<ReloadControl
- onReload={() => { reloads += 1; }}
+ onReload={() => {
+ reloads += 1;
+ }}
isReloading
lastUpdated={new Date()}
/>,
@@ -2022,7 +2351,11 @@ test("AccessScreen shows the access itself when opened on the credential step",
(i) => (i as HTMLInputElement).value,
);
assert.ok(
- shown.some((v) => v === "taler-pos://merchant.example.com/instances/cafe#secret-token%3Ademo"),
+ shown.some(
+ (v) =>
+ v ===
+ "taler-pos://merchant.example.com/instances/cafe#secret-token%3Ademo",
+ ),
"the access is not shown as a copyable string",
);
assert.ok(
@@ -2042,7 +2375,9 @@ test("AccessScreen requests the safe default POS credential", async () => {
render(
<AccessScreen
tokens={[]}
- onCreatePairing={async (request) => { requests.push(request); }}
+ onCreatePairing={async (request) => {
+ requests.push(request);
+ }}
/>,
container,
);
@@ -2052,7 +2387,9 @@ test("AccessScreen requests the safe default POS credential", async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
const name = container.querySelector("#pair_dev_name") as HTMLInputElement;
- const password = container.querySelector("#pair_password") as HTMLInputElement;
+ const password = container.querySelector(
+ "#pair_password",
+ ) as HTMLInputElement;
act(() => {
name.value = "Front counter";
name.dispatchEvent(new Event("input", { bubbles: true }));
@@ -2064,12 +2401,14 @@ test("AccessScreen requests the safe default POS credential", async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
- assert.deepStrictEqual(requests, [{
- deviceName: "Front counter",
- password: "master secret",
- lifetime: "10d",
- refreshable: true,
- }]);
+ assert.deepStrictEqual(requests, [
+ {
+ deviceName: "Front counter",
+ password: "master secret",
+ lifetime: "10d",
+ refreshable: true,
+ },
+ ]);
render(null, container);
document.body.removeChild(container);
@@ -2083,7 +2422,9 @@ test("AccessScreen makes unlimited POS access non-refreshable", async () => {
render(
<AccessScreen
tokens={[]}
- onCreatePairing={async (request) => { requests.push(request); }}
+ onCreatePairing={async (request) => {
+ requests.push(request);
+ }}
/>,
container,
);
@@ -2096,18 +2437,24 @@ test("AccessScreen makes unlimited POS access non-refreshable", async () => {
findButton(container, "Show advanced settings")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
- const lifetime = container.querySelector("#pair_lifetime") as HTMLSelectElement;
+ const lifetime = container.querySelector(
+ "#pair_lifetime",
+ ) as HTMLSelectElement;
await act(async () => {
lifetime.value = "never";
lifetime.dispatchEvent(new Event("change", { bubbles: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
- const refreshable = container.querySelector('input[type="checkbox"]') as HTMLInputElement;
+ const refreshable = container.querySelector(
+ 'input[type="checkbox"]',
+ ) as HTMLInputElement;
assert.strictEqual(refreshable.disabled, true);
assert.strictEqual(refreshable.checked, false);
const name = container.querySelector("#pair_dev_name") as HTMLInputElement;
- const password = container.querySelector("#pair_password") as HTMLInputElement;
+ const password = container.querySelector(
+ "#pair_password",
+ ) as HTMLInputElement;
act(() => {
name.value = "Front counter";
name.dispatchEvent(new Event("input", { bubbles: true }));
@@ -2119,9 +2466,13 @@ test("AccessScreen makes unlimited POS access non-refreshable", async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
- assert.deepStrictEqual(requests.map(({ lifetime: l, refreshable: r }) => ({ lifetime: l, refreshable: r })), [
- { lifetime: "never", refreshable: false },
- ]);
+ assert.deepStrictEqual(
+ requests.map(({ lifetime: l, refreshable: r }) => ({
+ lifetime: l,
+ refreshable: r,
+ })),
+ [{ lifetime: "never", refreshable: false }],
+ );
render(null, container);
document.body.removeChild(container);
@@ -2137,22 +2488,32 @@ test("AccessScreen tells the merchant that closing does not revoke issued till a
tokens={[]}
issuedPairingCredential={{
deviceName: "Front counter",
- pairingUri: "taler-pos://merchant.example.com/instances/cafe#secret-token%3Ademo",
+ pairingUri:
+ "taler-pos://merchant.example.com/instances/cafe#secret-token%3Ademo",
expirationText: "2026-08-28 10:00",
}}
- onDismissPairing={() => { dismissed += 1; }}
+ onDismissPairing={() => {
+ dismissed += 1;
+ }}
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
await act(async () => {
findButton(container, "Close without pairing")?.click();
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.match(container.textContent ?? "", /will remain active/);
- assert.match(container.textContent ?? "", /revoke it from the machine access list/);
- await act(async () => { findButton(container, "Close and review access")?.click(); });
+ assert.match(
+ container.textContent ?? "",
+ /revoke it from the machine access list/,
+ );
+ await act(async () => {
+ findButton(container, "Close and review access")?.click();
+ });
assert.strictEqual(dismissed, 1);
assert.strictEqual(container.querySelector('[role="dialog"]'), null);
@@ -2167,14 +2528,17 @@ test("AccessScreen disables POS pairing when the backend cannot be represented",
render(
<AccessScreen
tokens={[]}
- onCreatePairing={async () => { throw new Error("must not be called"); }}
+ onCreatePairing={async () => {
+ throw new Error("must not be called");
+ }}
pairingUnavailableReason="Till pairing cannot represent a custom port."
/>,
container,
);
- const buttons = Array.from(container.querySelectorAll("button"))
- .filter((button) => button.textContent?.includes("Pair a till"));
+ const buttons = Array.from(container.querySelectorAll("button")).filter(
+ (button) => button.textContent?.includes("Pair a till"),
+ );
assert.ok(buttons.length > 0);
assert.ok(buttons.every((button) => button.disabled));
assert.match(container.textContent ?? "", /Till pairing is unavailable/);
@@ -2234,9 +2598,18 @@ test("ReportsScreen renders scheduled reports and groupings tab", () => {
test("ReportsScreen never fabricates report source or destination from a summary", () => {
const container = document.createElement("div");
document.body.appendChild(container);
- render(<ReportsScreen sampleReports={[{
- report_serial: 1, description: "External report", report_frequency: { d_us: 86_400_000_000 },
- }]} />, container);
+ render(
+ <ReportsScreen
+ sampleReports={[
+ {
+ report_serial: 1,
+ description: "External report",
+ report_frequency: { d_us: 86_400_000_000 },
+ },
+ ]}
+ />,
+ container,
+ );
assert.match(container.textContent ?? "", /Unavailable/);
assert.doesNotMatch(container.textContent ?? "", /Sales and revenue summary/);
render(null, container);
@@ -2250,17 +2623,27 @@ test("ReportsScreen preserves an authoritative money-pot description while renam
render(
<ReportsScreen
initialTab="groupings"
- samplePots={[{
- pot_serial: 17,
- pot_name: "reserve",
- description: "Keep this allocation rule",
- pot_totals: ["CHF:4"],
- }]}
- onUpdatePot={async (_serial, request) => { updated = request; }}
+ samplePots={[
+ {
+ pot_serial: 17,
+ pot_name: "reserve",
+ description: "Keep this allocation rule",
+ pot_totals: ["CHF:4"],
+ },
+ ]}
+ onUpdatePot={async (_serial, request) => {
+ updated = request;
+ }}
/>,
container,
);
- act(() => (container.querySelector('button[aria-label="Actions for reserve"]') as HTMLButtonElement).click());
+ act(() =>
+ (
+ container.querySelector(
+ 'button[aria-label="Actions for reserve"]',
+ ) as HTMLButtonElement
+ ).click(),
+ );
const edit = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Edit",
) as HTMLButtonElement;
@@ -2271,9 +2654,10 @@ test("ReportsScreen preserves an authoritative money-pot description while renam
"Keep this allocation rule",
);
await act(async () => {
- document.body.querySelector("#pot_desc_input")?.closest("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ document.body
+ .querySelector("#pot_desc_input")
+ ?.closest("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
assert.deepStrictEqual(updated, {
@@ -2288,12 +2672,19 @@ test("WebhooksScreen renders configured endpoints without inventing defaults", (
const container = document.createElement("div");
document.body.appendChild(container);
- render(<WebhooksScreen webhooks={[{
- id: "paid-orders",
- url: "https://merchant.example/webhook",
- event: "pay",
- httpMethod: "POST",
- }]} />, container);
+ render(
+ <WebhooksScreen
+ webhooks={[
+ {
+ id: "paid-orders",
+ url: "https://merchant.example/webhook",
+ event: "pay",
+ httpMethod: "POST",
+ },
+ ]}
+ />,
+ container,
+ );
assert.match(container.innerHTML, /Webhooks/);
// `order_paid` is not a real event: the enum is order_created / pay / refund /
@@ -2301,12 +2692,18 @@ test("WebhooksScreen renders configured endpoints without inventing defaults", (
// a friendly label rather than any raw slug.
assert.match(container.innerHTML, /Order paid/);
assert.doesNotMatch(container.innerHTML, /order_paid/);
- assert.doesNotMatch(container.textContent ?? "", /Last Result|Never called|Delivered/);
+ assert.doesNotMatch(
+ container.textContent ?? "",
+ /Last Result|Never called|Delivered/,
+ );
assert.equal(container.querySelectorAll("thead th").length, 3);
render(<WebhooksScreen webhooks={[]} />, container);
assert.match(container.textContent ?? "", /No webhooks configured yet/);
assert.doesNotMatch(container.innerHTML, /api\.example\.com/);
- assert.equal(container.querySelector("tbody td")?.getAttribute("colspan"), "3");
+ assert.equal(
+ container.querySelector("tbody td")?.getAttribute("colspan"),
+ "3",
+ );
render(null, container); // unmount so effect cleanups run
document.body.removeChild(container);
@@ -2318,18 +2715,23 @@ test("WebhooksScreen surfaces endpoint detail failures", () => {
render(
<WebhooksScreen
- webhooks={[{
- id: "paid-orders",
- url: "",
- event: "pay",
- detailsLoaded: false,
- detailError: new Error("offline"),
- }]}
+ webhooks={[
+ {
+ id: "paid-orders",
+ url: "",
+ event: "pay",
+ detailsLoaded: false,
+ detailError: new Error("offline"),
+ },
+ ]}
/>,
container,
);
- assert.match(container.textContent ?? "", /Webhook details could not be loaded/);
+ assert.match(
+ container.textContent ?? "",
+ /Webhook details could not be loaded/,
+ );
assert.ok(container.querySelector('[role="alert"]'));
render(null, container);
@@ -2347,13 +2749,19 @@ test("CreateWebhookScreen starts empty and accepts only HTTP(S) targets", async
document.body.appendChild(container);
const saved: any[] = [];
render(
- <CreateWebhookScreen onSave={async (webhook) => { saved.push(webhook); }} />,
+ <CreateWebhookScreen
+ onSave={async (webhook) => {
+ saved.push(webhook);
+ }}
+ />,
container,
);
const webhookId = container.querySelector("#wh_id_input") as HTMLInputElement;
const url = container.querySelector("#wh_url_input") as HTMLInputElement;
- const headers = container.querySelector("#wh_headers_input") as HTMLTextAreaElement;
+ const headers = container.querySelector(
+ "#wh_headers_input",
+ ) as HTMLTextAreaElement;
const body = container.querySelector("#wh_body_input") as HTMLTextAreaElement;
assert.strictEqual(url.value, "");
assert.strictEqual(headers.value, "");
@@ -2366,9 +2774,9 @@ test("CreateWebhookScreen starts empty and accepts only HTTP(S) targets", async
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(saved.length, 0);
assert.match(container.textContent ?? "", /valid HTTP or HTTPS/);
@@ -2376,9 +2784,9 @@ test("CreateWebhookScreen starts empty and accepts only HTTP(S) targets", async
await act(async () => {
url.value = "file:///tmp/hook";
url.dispatchEvent(new Event("input", { bubbles: true }));
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(saved.length, 0);
@@ -2387,9 +2795,9 @@ test("CreateWebhookScreen starts empty and accepts only HTTP(S) targets", async
url.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.strictEqual(saved.length, 1);
@@ -2423,7 +2831,10 @@ test("CreateWebhookScreen blocks edits when authoritative detail fails", () => {
container,
);
- assert.match(container.textContent ?? "", /Webhook details could not be loaded/);
+ assert.match(
+ container.textContent ?? "",
+ /Webhook details could not be loaded/,
+ );
assert.strictEqual(container.querySelector("form"), null);
render(null, container);
@@ -2433,10 +2844,8 @@ test("CreateWebhookScreen blocks edits when authoritative detail fails", () => {
test("CreateDeviceScreen hydrates a delayed algorithm-2 edit exactly once", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
- const staticLocation = () => [
- "/authenticators/counter-terminal/edit",
- () => undefined,
- ] as const;
+ const staticLocation = () =>
+ ["/authenticators/counter-terminal/edit", () => undefined] as const;
const updates: Array<{ id: string; name: string; algorithm: number }> = [];
const loadingResource: RemoteResource<any[]> = {
data: undefined,
@@ -2448,14 +2857,20 @@ test("CreateDeviceScreen hydrates a delayed algorithm-2 edit exactly once", asyn
render(
<Router hook={staticLocation as never}>
- <CreateDeviceScreen editId="counter-terminal" devices={[]} devicesResource={loadingResource} />
+ <CreateDeviceScreen
+ editId="counter-terminal"
+ devices={[]}
+ devicesResource={loadingResource}
+ />
</Router>,
container,
);
assert.match(container.textContent ?? "", /Loading/);
assert.strictEqual(container.querySelector("form"), null);
- const devices = [{ id: "counter-terminal", name: "Counter terminal", algorithm: 2 }];
+ const devices = [
+ { id: "counter-terminal", name: "Counter terminal", algorithm: 2 },
+ ];
const readyResource: RemoteResource<typeof devices> = {
data: devices,
error: undefined,
@@ -2489,16 +2904,18 @@ test("CreateDeviceScreen hydrates a delayed algorithm-2 edit exactly once", asyn
true,
);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
- assert.deepStrictEqual(updates, [{
- id: "counter-terminal",
- name: "Counter terminal",
- algorithm: 2,
- }]);
+ assert.deepStrictEqual(updates, [
+ {
+ id: "counter-terminal",
+ name: "Counter terminal",
+ algorithm: 2,
+ },
+ ]);
render(null, container);
document.body.removeChild(container);
@@ -2511,9 +2928,14 @@ test("SubscriptionsScreen uses Discounts & Passes terminology with discounts fir
render(<SubscriptionsScreen />, container);
assert.match(container.textContent ?? "", /Discounts & Passes/);
- const tabs = Array.from(container.querySelectorAll("nav button"))
- .map((button) => button.textContent?.trim());
- assert.deepStrictEqual(tabs, ["All discounts and passes (0)", "Discounts", "Passes"]);
+ const tabs = Array.from(container.querySelectorAll("nav button")).map(
+ (button) => button.textContent?.trim(),
+ );
+ assert.deepStrictEqual(tabs, [
+ "All discounts and passes (0)",
+ "Discounts",
+ "Passes",
+ ]);
assert.doesNotMatch(container.innerHTML, /How subscriptions and passes work/);
render(null, container); // unmount so effect cleanups run
@@ -2524,11 +2946,7 @@ test("entity list names link to their established editor routes", () => {
const container = document.createElement("div");
document.body.appendChild(container);
- const assertNameLinks = (
- screen: VNode,
- name: string,
- href: string,
- ) => {
+ const assertNameLinks = (screen: VNode, name: string, href: string) => {
render(screen, container);
const links = Array.from(
container.querySelectorAll<HTMLAnchorElement>(`a[href="${href}"]`),
@@ -2543,34 +2961,40 @@ test("entity list names link to their established editor routes", () => {
assertNameLinks(
<SubscriptionsScreen
- items={[{
- id: "monthly-pass",
- name: "Monthly pass",
- kind: "subscription",
- canBeUsed: "Once a month",
- }]}
+ items={[
+ {
+ id: "monthly-pass",
+ name: "Monthly pass",
+ kind: "subscription",
+ canBeUsed: "Once a month",
+ },
+ ]}
/>,
"Monthly pass",
"#/subscriptions/monthly-pass/edit",
);
assertNameLinks(
<WebhooksScreen
- webhooks={[{
- id: "paid-orders",
- url: "https://merchant.example/webhooks/paid",
- event: "pay",
- }]}
+ webhooks={[
+ {
+ id: "paid-orders",
+ url: "https://merchant.example/webhooks/paid",
+ event: "pay",
+ },
+ ]}
/>,
"paid-orders",
"#/settings/webhooks/paid-orders/edit",
);
assertNameLinks(
<DevicesScreen
- authenticators={[{
- id: "counter-terminal",
- name: "Counter terminal",
- algorithm: 1,
- }]}
+ authenticators={[
+ {
+ id: "counter-terminal",
+ name: "Counter terminal",
+ algorithm: 1,
+ },
+ ]}
/>,
"Counter terminal",
"#/authenticators/counter-terminal/edit",
@@ -2590,22 +3014,38 @@ function currencySpec(digits: number): CurrencySpecification {
}
test("CreateSubscriptionScreen derives precision from currency metadata without overwriting edits", async () => {
- assert.strictEqual(currencyFractionalStep("JPY", { JPY: currencySpec(0) }), "1");
- assert.strictEqual(currencyFractionalStep("CHF", { CHF: currencySpec(2) }), "0.01");
- assert.strictEqual(currencyFractionalStep("BTC", { BTC: currencySpec(8) }), "0.00000001");
+ assert.strictEqual(
+ currencyFractionalStep("JPY", { JPY: currencySpec(0) }),
+ "1",
+ );
+ assert.strictEqual(
+ currencyFractionalStep("CHF", { CHF: currencySpec(2) }),
+ "0.01",
+ );
+ assert.strictEqual(
+ currencyFractionalStep("BTC", { BTC: currencySpec(8) }),
+ "0.00000001",
+ );
assert.strictEqual(currencyFractionalStep("UNKNOWN", {}), "0.01");
const container = document.createElement("div");
document.body.appendChild(container);
- act(() => render(
- <CreateSubscriptionScreen
- primaryCurrency="CHF"
- currencySpecifications={{ CHF: currencySpec(2) }}
- />,
- container,
- ));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
- assert.strictEqual(container.querySelector("#sub_discount_rounding_precision"), null);
+ act(() =>
+ render(
+ <CreateSubscriptionScreen
+ primaryCurrency="CHF"
+ currencySpecifications={{ CHF: currencySpec(2) }}
+ />,
+ container,
+ ),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(
+ container.querySelector("#sub_discount_rounding_precision"),
+ null,
+ );
const roundingButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Rounding options"),
);
@@ -2613,38 +3053,60 @@ test("CreateSubscriptionScreen derives precision from currency metadata without
assert.strictEqual(roundingButton.getAttribute("aria-expanded"), "false");
act(() => roundingButton.click());
assert.strictEqual(
- (container.querySelector("#sub_discount_rounding_precision") as HTMLInputElement).value,
+ (
+ container.querySelector(
+ "#sub_discount_rounding_precision",
+ ) as HTMLInputElement
+ ).value,
"0.01",
);
- act(() => render(
- <CreateSubscriptionScreen
- primaryCurrency="BTC"
- currencySpecifications={{ BTC: currencySpec(8) }}
- />,
- container,
- ));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ act(() =>
+ render(
+ <CreateSubscriptionScreen
+ primaryCurrency="BTC"
+ currencySpecifications={{ BTC: currencySpec(8) }}
+ />,
+ container,
+ ),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(
- (container.querySelector("#sub_discount_rounding_precision") as HTMLInputElement).value,
+ (
+ container.querySelector(
+ "#sub_discount_rounding_precision",
+ ) as HTMLInputElement
+ ).value,
"0.00000001",
);
act(() => {
- const precision = container.querySelector("#sub_discount_rounding_precision") as HTMLInputElement;
+ const precision = container.querySelector(
+ "#sub_discount_rounding_precision",
+ ) as HTMLInputElement;
precision.value = "0.05";
precision.dispatchEvent(new Event("input", { bubbles: true }));
});
- act(() => render(
- <CreateSubscriptionScreen
- primaryCurrency="JPY"
- currencySpecifications={{ JPY: currencySpec(0) }}
- />,
- container,
- ));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ act(() =>
+ render(
+ <CreateSubscriptionScreen
+ primaryCurrency="JPY"
+ currencySpecifications={{ JPY: currencySpec(0) }}
+ />,
+ container,
+ ),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(
- (container.querySelector("#sub_discount_rounding_precision") as HTMLInputElement).value,
+ (
+ container.querySelector(
+ "#sub_discount_rounding_precision",
+ ) as HTMLInputElement
+ ).value,
"0.05",
);
@@ -2673,11 +3135,13 @@ test("CreateSubscriptionScreen blocks failed and opaque authoritative edits", ()
render(
<CreateSubscriptionScreen
editId="opaque"
- families={[{
- slug: "opaque",
- name: "Summary only",
- kind: "discount",
- }]}
+ families={[
+ {
+ slug: "opaque",
+ name: "Summary only",
+ kind: "discount",
+ },
+ ]}
familyResource={failed}
/>,
container,
@@ -2717,49 +3181,60 @@ test("CreateSubscriptionScreen edits multi-currency flat caps", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
let saved: any;
- act(() => render(
- <CreateSubscriptionScreen
- categories={[{ id: 1, name: "Coffee", productCount: 1 }]}
- primaryCurrency="CHF"
- currencySpecifications={{
- CHF: currencySpec(2),
- BTC: currencySpec(8),
- }}
- initialItem={{
- id: "coffee-caps",
- name: "Coffee caps",
- description: "Currency-specific caps",
- kind: "subscription",
- canBeUsed: "Always",
- extraData: {
- experimental_subscription: {
- type: "flat",
- amount: ["CHF:10", "BTC:0.0001"],
- product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
+ act(() =>
+ render(
+ <CreateSubscriptionScreen
+ categories={[{ id: 1, name: "Coffee", productCount: 1 }]}
+ primaryCurrency="CHF"
+ currencySpecifications={{
+ CHF: currencySpec(2),
+ BTC: currencySpec(8),
+ }}
+ initialItem={{
+ id: "coffee-caps",
+ name: "Coffee caps",
+ description: "Currency-specific caps",
+ kind: "subscription",
+ canBeUsed: "Always",
+ extraData: {
+ experimental_subscription: {
+ type: "flat",
+ amount: ["CHF:10", "BTC:0.0001"],
+ product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
+ },
},
- },
- }}
- onSave={(item) => { saved = item; }}
- />,
- container,
- ));
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ }}
+ onSave={(item) => {
+ saved = item;
+ }}
+ />,
+ container,
+ ),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.ok(container.querySelector("#sub_discount_flat_0"));
- const caps = Array.from(container.querySelectorAll('input[id^="sub_discount_flat_"]')) as HTMLInputElement[];
- assert.deepStrictEqual(caps.map((input) => [input.value, input.step]), [
- ["10", "0.01"],
- ["0.0001", "0.00000001"],
- ]);
+ const caps = Array.from(
+ container.querySelectorAll('input[id^="sub_discount_flat_"]'),
+ ) as HTMLInputElement[];
+ assert.deepStrictEqual(
+ caps.map((input) => [input.value, input.step]),
+ [
+ ["10", "0.01"],
+ ["0.0001", "0.00000001"],
+ ],
+ );
const addButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Add currency cap"),
);
assert.strictEqual(addButton?.disabled, true);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.deepStrictEqual(saved.extraData.experimental_subscription.amount, [
"CHF:10",
@@ -2776,7 +3251,8 @@ test("CreateSubscriptionScreen gives discount redemption and earning rules paral
render(<CreateSubscriptionScreen categories={[]} />, container);
assert.match(container.textContent ?? "", /Create Discount/);
const kindButtons = Array.from(container.querySelectorAll("button")).filter(
- (button) => button.textContent?.includes("Promotional or loyalty benefit") ||
+ (button) =>
+ button.textContent?.includes("Promotional or loyalty benefit") ||
button.textContent?.includes("Time-based access pass"),
);
assert.strictEqual(kindButtons.length, 2);
@@ -2795,14 +3271,17 @@ test("CreateSubscriptionScreen gives discount redemption and earning rules paral
assert.ok(legends.includes("Products where the benefit applies"));
assert.ok(legends.includes("Products where discounts are earned"));
- const redeemingSection = Array.from(container.querySelectorAll("section")).find(
- (section) => section.textContent?.includes("Redeeming discounts"),
- );
+ const redeemingSection = Array.from(
+ container.querySelectorAll("section"),
+ ).find((section) => section.textContent?.includes("Redeeming discounts"));
const earningSection = Array.from(container.querySelectorAll("section")).find(
(section) => section.textContent?.includes("Earning discounts"),
);
assert.ok(redeemingSection?.querySelector("#sub_required_tokens"));
- assert.strictEqual(earningSection?.querySelector("#sub_required_tokens"), null);
+ assert.strictEqual(
+ earningSection?.querySelector("#sub_required_tokens"),
+ null,
+ );
assert.match(
container.textContent ?? "",
/No product categories are available\. Create a category or select an individual product\./,
@@ -2887,11 +3366,15 @@ test("CreateSubscriptionScreen loads, submits, and clears explicit validity date
},
},
}}
- onSave={(item) => { saved = item; }}
+ onSave={(item) => {
+ saved = item;
+ }}
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
const advancedButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Show advanced options"),
);
@@ -2951,7 +3434,9 @@ test("CreateSubscriptionScreen saves nested semantics and preserves unrelated ex
type: "percentage",
percentage: "20",
rounding: { mode: "nearest", precision: "0.05" },
- product_selectors: [{ type: "category", id: 1, name: "Old drinks name" }],
+ product_selectors: [
+ { type: "category", id: 1, name: "Old drinks name" },
+ ],
required_tokens: 5,
issuance: {
product_selectors: "*",
@@ -2975,7 +3460,11 @@ test("CreateSubscriptionScreen saves nested semantics and preserves unrelated ex
assert.ok(roundingButton);
assert.strictEqual(roundingButton.getAttribute("aria-expanded"), "true");
assert.strictEqual(
- (container.querySelector("#sub_discount_rounding_precision") as HTMLInputElement).value,
+ (
+ container.querySelector(
+ "#sub_discount_rounding_precision",
+ ) as HTMLInputElement
+ ).value,
"0.05",
);
const categoryCheckbox = Array.from(container.querySelectorAll("label"))
@@ -3041,9 +3530,13 @@ test("CreateSubscriptionScreen refreshes independent redemption and issuance cat
type: "flat",
amount: "CHF:10",
required_tokens: 5,
- product_selectors: [{ type: "category", id: 1, name: "Old coffee" }],
+ product_selectors: [
+ { type: "category", id: 1, name: "Old coffee" },
+ ],
issuance: {
- product_selectors: [{ type: "category", id: 3, name: "Old hot drinks" }],
+ product_selectors: [
+ { type: "category", id: 3, name: "Old hot drinks" },
+ ],
minimum_purchase: "EUR:5",
issue_on_redemption: true,
},
@@ -3086,26 +3579,27 @@ test("CreateSubscriptionScreen can save discounts and passes without a redemptio
const container = document.createElement("div");
document.body.appendChild(container);
let saved: any;
- const extraData = kind === "subscription"
- ? {
- experimental_subscription: {
- type: "percentage",
- percentage: "20",
- product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
- },
- }
- : {
- experimental_discount: {
- type: "percentage",
- percentage: "20",
- product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
- required_tokens: 5,
- issuance: {
- product_selectors: "*",
- issue_on_redemption: false,
+ const extraData =
+ kind === "subscription"
+ ? {
+ experimental_subscription: {
+ type: "percentage",
+ percentage: "20",
+ product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
},
- },
- };
+ }
+ : {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "20",
+ product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
+ required_tokens: 5,
+ issuance: {
+ product_selectors: "*",
+ issue_on_redemption: false,
+ },
+ },
+ };
render(
<CreateSubscriptionScreen
@@ -3134,7 +3628,9 @@ test("CreateSubscriptionScreen can save discounts and passes without a redemptio
assert.ok(noneRadio);
act(() => noneRadio.click());
assert.strictEqual(
- container.querySelector('[aria-label="Apply benefit to all merchant purchases"]'),
+ container.querySelector(
+ '[aria-label="Apply benefit to all merchant purchases"]',
+ ),
null,
);
assert.strictEqual(container.querySelector("#sub_required_tokens"), null);
@@ -3251,7 +3747,9 @@ test("CreateSubscriptionScreen edits discount free-item benefits", async () => {
experimental_discount: {
type: "free_item",
required_tokens: 5,
- product_selectors: [{ type: "category", id: 1, name: "Old coffee" }],
+ product_selectors: [
+ { type: "category", id: 1, name: "Old coffee" },
+ ],
issuance: {
product_selectors: "*",
issue_on_redemption: false,
@@ -3403,24 +3901,33 @@ test("CreateSubscriptionScreen retains unavailable snapshot names and blocks sav
experimental_subscription: {
type: "flat",
amount: "CHF:3",
- product_selectors: [{ type: "category", id: 1, name: "Deleted coffee" }],
+ product_selectors: [
+ { type: "category", id: 1, name: "Deleted coffee" },
+ ],
},
},
}}
- onSave={() => { saved = true; }}
+ onSave={() => {
+ saved = true;
+ }}
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(
container.querySelector('input[name="discount_type"][value="free_item"]'),
null,
);
- assert.match(container.textContent ?? "", /Deleted coffee \(unavailable category #1\)/);
+ assert.match(
+ container.textContent ?? "",
+ /Deleted coffee \(unavailable category #1\)/,
+ );
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(saved, false);
assert.match(container.textContent ?? "", /Remove unavailable categories/);
@@ -3463,7 +3970,9 @@ test("DurationInput reinterprets the number in a selected fixed unit", () => {
let lastUs: DurationValue = 0;
function Harness(): VNode {
- const [valueUs, setValueUs] = useState<DurationValue>(156 * 3600 * 1_000_000);
+ const [valueUs, setValueUs] = useState<DurationValue>(
+ 156 * 3600 * 1_000_000,
+ );
return (
<DurationInput
valueUs={valueUs}
@@ -3500,7 +4009,9 @@ test("DurationInput reinterprets a controlled number without focus", () => {
document.body.appendChild(container);
function Harness(): VNode {
- const [valueUs, setValueUs] = useState<DurationValue>(24 * 3600 * 1_000_000);
+ const [valueUs, setValueUs] = useState<DurationValue>(
+ 24 * 3600 * 1_000_000,
+ );
return <DurationInput valueUs={valueUs} onChangeUs={setValueUs} />;
}
render(<Harness />, container);
@@ -3525,7 +4036,9 @@ test("DurationInput reinterprets a focused fixed number when selecting a unit",
let lastUs: DurationValue = 23 * 3600 * 1_000_000;
function Harness(): VNode {
- const [valueUs, setValueUs] = useState<DurationValue>(23 * 3600 * 1_000_000);
+ const [valueUs, setValueUs] = useState<DurationValue>(
+ 23 * 3600 * 1_000_000,
+ );
return (
<DurationInput
valueUs={valueUs}
@@ -3602,7 +4115,9 @@ test("DurationInput preserves a focused fixed value when entering custom mode",
document.body.appendChild(container);
function Harness(): VNode {
- const [valueUs, setValueUs] = useState<DurationValue>(23 * 3600 * 1_000_000);
+ const [valueUs, setValueUs] = useState<DurationValue>(
+ 23 * 3600 * 1_000_000,
+ );
return <DurationInput valueUs={valueUs} onChangeUs={setValueUs} />;
}
render(<Harness />, container);
@@ -3617,7 +4132,10 @@ test("DurationInput preserves a focused fixed value when entering custom mode",
});
assert.strictEqual(select.value, "custom");
- assert.strictEqual((container.querySelector("input") as HTMLInputElement).value, "23h");
+ assert.strictEqual(
+ (container.querySelector("input") as HTMLInputElement).value,
+ "23h",
+ );
render(null, container);
document.body.removeChild(container);
@@ -3645,7 +4163,10 @@ test("DurationInput normalizes fractional and composite custom transitions", ()
select.value = "custom";
select.dispatchEvent(new Event("change", { bubbles: true }));
});
- assert.strictEqual((container.querySelector("input") as HTMLInputElement).value, "1h 30m");
+ assert.strictEqual(
+ (container.querySelector("input") as HTMLInputElement).value,
+ "1h 30m",
+ );
input = container.querySelector("input") as HTMLInputElement;
input.focus();
@@ -3659,7 +4180,10 @@ test("DurationInput normalizes fractional and composite custom transitions", ()
select.dispatchEvent(new Event("change", { bubbles: true }));
});
assert.strictEqual(select.value, "hours");
- assert.strictEqual((container.querySelector("input") as HTMLInputElement).value, "28");
+ assert.strictEqual(
+ (container.querySelector("input") as HTMLInputElement).value,
+ "28",
+ );
render(null, container);
document.body.removeChild(container);
@@ -3669,7 +4193,8 @@ test("DurationInput re-infers external values and uses custom for unwieldy value
const container = document.createElement("div");
document.body.appendChild(container);
- let setExternalValue: (us: DurationValue) => void = () => assert.fail("harness did not render");
+ let setExternalValue: (us: DurationValue) => void = () =>
+ assert.fail("harness did not render");
function Harness(): VNode {
const [valueUs, setValueUs] = useState<DurationValue>(999 * 1_000_000);
setExternalValue = setValueUs;
@@ -3714,7 +4239,9 @@ test("DurationInput custom mode accepts GNUnet duration units", () => {
onChangeUs={(us) => {
lastUs = us;
}}
- onValidityChange={(next) => { valid = next; }}
+ onValidityChange={(next) => {
+ valid = next;
+ }}
/>,
container,
);
@@ -3801,10 +4328,7 @@ test("CreateProductScreen offers every payout currency", () => {
document.body.appendChild(container);
render(
- <CreateProductScreen
- currency="CHF"
- payoutCurrencies={["KUDOS", "USD"]}
- />,
+ <CreateProductScreen currency="CHF" payoutCurrencies={["KUDOS", "USD"]} />,
container,
);
@@ -3854,22 +4378,30 @@ test("CreateProductScreen hydrates authoritative fields and preserves empty cate
/>,
container,
);
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)); });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
assert.strictEqual(
(container.querySelector("#prod_unit_select") as HTMLSelectElement).value,
"other",
);
assert.strictEqual(
- (container.querySelector('input[placeholder="e.g. packet, barrel, sachet"]') as HTMLInputElement).value,
+ (
+ container.querySelector(
+ 'input[placeholder="e.g. packet, barrel, sachet"]',
+ ) as HTMLInputElement
+ ).value,
"sachet",
);
assert.strictEqual(
- (container.querySelector("#prod_stock_count_input") as HTMLInputElement).value,
+ (container.querySelector("#prod_stock_count_input") as HTMLInputElement)
+ .value,
"7",
);
assert.strictEqual(
- (container.querySelector("#prod_restock_date_input") as HTMLInputElement).value,
+ (container.querySelector("#prod_restock_date_input") as HTMLInputElement)
+ .value,
"2026-09-15",
);
act(() => findButton(container, "Show advanced options")?.click());
@@ -3879,9 +4411,9 @@ test("CreateProductScreen hydrates authoritative fields and preserves empty cate
);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -3912,21 +4444,26 @@ test("CreateProductScreen never edits from list summary data", () => {
render(
<CreateProductScreen
editId="summary-only"
- products={[{
- id: "summary-only",
- name: "Summary only",
- price: "CHF:1",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "",
- }]}
+ products={[
+ {
+ id: "summary-only",
+ name: "Summary only",
+ price: "CHF:1",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "",
+ },
+ ]}
productResource={failed}
/>,
container,
);
- assert.match(container.textContent ?? "", /Product details could not be loaded/);
+ assert.match(
+ container.textContent ?? "",
+ /Product details could not be loaded/,
+ );
assert.strictEqual(container.querySelector("form"), null);
render(null, container);
@@ -3946,12 +4483,15 @@ test("CreateAccessScreen never invents a credential when creation is unavailable
password.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
await Promise.resolve();
});
- assert.match(container.textContent ?? "", /backend did not return a machine access token/);
+ assert.match(
+ container.textContent ?? "",
+ /backend did not return a machine access token/,
+ );
assert.doesNotMatch(container.textContent ?? "", /Machine Access Created/);
assert.doesNotMatch(container.innerHTML, /secret_tok/);
render(null, container);
@@ -3966,13 +4506,31 @@ test("LocationInput preserves building name and town locality independently", ()
<LocationInput
idPrefix="location-test"
value={{ building_name: "Tower B", town_location: "Old Town" }}
- onChange={(value) => { changed = value; }}
+ onChange={(value) => {
+ changed = value;
+ }}
/>,
container,
);
- assert.equal((container.querySelector("#location-test-building-name") as HTMLInputElement).value, "Tower B");
- assert.equal((container.querySelector("#location-test-town-location") as HTMLInputElement).value, "Old Town");
- const building = container.querySelector("#location-test-building-name") as HTMLInputElement;
+ assert.equal(
+ (
+ container.querySelector(
+ "#location-test-building-name",
+ ) as HTMLInputElement
+ ).value,
+ "Tower B",
+ );
+ assert.equal(
+ (
+ container.querySelector(
+ "#location-test-town-location",
+ ) as HTMLInputElement
+ ).value,
+ "Old Town",
+ );
+ const building = container.querySelector(
+ "#location-test-building-name",
+ ) as HTMLInputElement;
act(() => {
building.value = "";
building.dispatchEvent(new Event("input", { bubbles: true }));
@@ -4051,11 +4609,13 @@ test("PosScreen sends exact one-off line totals without inventory duplication",
assert.ok(openCustom);
act(() => openCustom.click());
- const modal = Array.from(container.querySelectorAll("h3")).find(
- (heading) => heading.textContent?.includes("Add Ad-hoc Custom Item"),
+ const modal = Array.from(container.querySelectorAll("h3")).find((heading) =>
+ heading.textContent?.includes("Add Ad-hoc Custom Item"),
)?.parentElement as HTMLElement;
assert.ok(modal);
- const description = modal.querySelector('input[type="text"]') as HTMLInputElement;
+ const description = modal.querySelector(
+ 'input[type="text"]',
+ ) as HTMLInputElement;
const price = modal.querySelector('input[type="number"]') as HTMLInputElement;
act(() => {
description.value = "Exact service";
@@ -4064,9 +4624,9 @@ test("PosScreen sends exact one-off line totals without inventory duplication",
price.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
- modal.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ modal
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
const increase = container.querySelector(
@@ -4082,12 +4642,14 @@ test("PosScreen sends exact one-off line totals without inventory duplication",
await act(async () => charge.click());
assert.strictEqual(submitted.order.amount, "KUDOS:3.00000003");
- assert.deepStrictEqual(submitted.order.products, [{
- product_name: "Exact service",
- description: "Exact service",
- unit_quantity: "3",
- prices: ["KUDOS:3.00000003"],
- }]);
+ assert.deepStrictEqual(submitted.order.products, [
+ {
+ product_name: "Exact service",
+ description: "Exact service",
+ unit_quantity: "3",
+ prices: ["KUDOS:3.00000003"],
+ },
+ ]);
assert.strictEqual(submitted.order.products[0].quantity, undefined);
assert.strictEqual(submitted.order.products[0].price, undefined);
assert.strictEqual(submitted.inventory_products, undefined);
@@ -4106,13 +4668,15 @@ test("PosScreen refunds the selected history order in its authoritative currency
<PosScreen
initialSubRoute="history"
primaryCurrency="CHF"
- orders={[{
- orderId: "selected-order",
- summary: "Partially returned sale",
- amount: "CHF:99",
- status: "refunded",
- createdAt: "today",
- }]}
+ orders={[
+ {
+ orderId: "selected-order",
+ summary: "Partially returned sale",
+ amount: "CHF:99",
+ status: "refunded",
+ createdAt: "today",
+ },
+ ]}
onLoadRefundOrder={async (orderId) => {
loadedOrderId = orderId;
return { orderId, remaining: "KUDOS:4.00000001" };
@@ -4164,7 +4728,9 @@ test("PosScreen awaits authoritative cancellation before leaving an unpaid order
document.body.appendChild(container);
let canceledId: string | undefined;
let finishCancel!: () => void;
- const cancellation = new Promise<void>((resolve) => { finishCancel = resolve; });
+ const cancellation = new Promise<void>((resolve) => {
+ finishCancel = resolve;
+ });
render(
<PosScreen
initialSubRoute="pay"
@@ -4293,35 +4859,43 @@ test("PosScreen previews and submits semantic token choices", async () => {
render(
<PosScreen
primaryCurrency="CHF"
- products={[{
- id: "espresso",
- name: "Espresso",
- price: "CHF:10",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "Drinks",
- categories: [1],
- }]}
- tokenFamilies={[{
- slug: "coffee20",
- name: "Coffee club",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "20",
- rounding: { mode: "nearest", precision: "0.01" },
- product_selectors: [{ type: "product", id: "espresso", name: "Espresso" }],
- required_tokens: 5,
- issuance: {
- product_selectors: [{ type: "product", id: "espresso", name: "Espresso" }],
- issue_on_redemption: false,
+ products={[
+ {
+ id: "espresso",
+ name: "Espresso",
+ price: "CHF:10",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "Drinks",
+ categories: [1],
+ },
+ ]}
+ tokenFamilies={[
+ {
+ slug: "coffee20",
+ name: "Coffee club",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "20",
+ rounding: { mode: "nearest", precision: "0.01" },
+ product_selectors: [
+ { type: "product", id: "espresso", name: "Espresso" },
+ ],
+ required_tokens: 5,
+ issuance: {
+ product_selectors: [
+ { type: "product", id: "espresso", name: "Espresso" },
+ ],
+ issue_on_redemption: false,
+ },
},
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "semantic-order";
@@ -4343,19 +4917,13 @@ test("PosScreen previews and submits semantic token choices", async () => {
container.textContent ?? "",
/Redeems: 5× Coffee club \(coffee20\)/,
);
- assert.match(
- container.textContent ?? "",
- /Pays CHF 8\.00 · saves CHF 2\.00/,
- );
+ assert.match(container.textContent ?? "", /Pays CHF 8\.00 · saves CHF 2\.00/);
const redemptionSummary = Array.from(
container.querySelectorAll("summary"),
).find((summary) => summary.textContent?.includes("Token effects"));
assert.ok(redemptionSummary);
act(() => redemptionSummary.click());
- assert.match(
- container.textContent ?? "",
- /Earned after this order is paid/,
- );
+ assert.match(container.textContent ?? "", /Earned after this order is paid/);
const issuedToken = container.querySelector(
'input[aria-label="Issue Coffee club (coffee20) for this order"]',
) as HTMLInputElement;
@@ -4418,27 +4986,29 @@ test("PosScreen submits the highest-priced eligible item as a free-item choice",
product("tea", "Tea", "CHF:4", [2]),
product("coffee", "Coffee", "CHF:6", [1]),
]}
- tokenFamilies={[{
- slug: "free-drink",
- name: "Free drink",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "free_item",
- price_selection: "most_expensive",
- product_selectors: [
- { type: "category", id: 1, name: "Coffee" },
- { type: "category", id: 2, name: "Tea" },
- ],
- required_tokens: 5,
- issuance: {
- product_selectors: "*",
- issue_on_redemption: false,
+ tokenFamilies={[
+ {
+ slug: "free-drink",
+ name: "Free drink",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "free_item",
+ price_selection: "most_expensive",
+ product_selectors: [
+ { type: "category", id: 1, name: "Coffee" },
+ { type: "category", id: 2, name: "Tea" },
+ ],
+ required_tokens: 5,
+ issuance: {
+ product_selectors: "*",
+ issue_on_redemption: false,
+ },
},
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "free-drink-order";
@@ -4453,7 +5023,10 @@ test("PosScreen submits the highest-priced eligible item as a free-item choice",
assert.ok(button);
act(() => button.click());
}
- assert.match(container.textContent ?? "", /Free drinkAutomatic choiceRedeems: 5× Free drink/);
+ assert.match(
+ container.textContent ?? "",
+ /Free drinkAutomatic choiceRedeems: 5× Free drink/,
+ );
assert.match(container.textContent ?? "", /Pays CHF 4\.00 · saves CHF 6\.00/);
await act(async () => {
(
@@ -4478,25 +5051,27 @@ test("PosScreen creates a v1 quick-amount order for wildcard token issuance", as
<PosScreen
initialSubRoute="amount"
primaryCurrency="CHF"
- tokenFamilies={[{
- slug: "all-purchases",
- name: "All purchases",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "flat",
- amount: "CHF:2",
- required_tokens: 3,
- product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
- issuance: {
- product_selectors: "*",
- minimum_purchase: "CHF:5",
- issue_on_redemption: false,
+ tokenFamilies={[
+ {
+ slug: "all-purchases",
+ name: "All purchases",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "flat",
+ amount: "CHF:2",
+ required_tokens: 3,
+ product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
+ issuance: {
+ product_selectors: "*",
+ minimum_purchase: "CHF:5",
+ issue_on_redemption: false,
+ },
},
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "quick-earned";
@@ -4515,16 +5090,20 @@ test("PosScreen creates a v1 quick-amount order for wildcard token issuance", as
container.textContent ?? "",
/Token effects1 token issued after payment/,
);
- const charge = container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement;
+ const charge = container.querySelector(
+ "#pos_charge_amount_btn",
+ ) as HTMLButtonElement;
await act(async () => charge.click());
assert.strictEqual(submitted.order.version, 1);
assert.strictEqual(submitted.order.amount, undefined);
assert.strictEqual(submitted.order.choices.length, 1);
- assert.deepStrictEqual(submitted.order.choices[0].outputs, [{
- type: "token",
- token_family_slug: "all-purchases",
- count: 1,
- }]);
+ assert.deepStrictEqual(submitted.order.choices[0].outputs, [
+ {
+ type: "token",
+ token_family_slug: "all-purchases",
+ count: 1,
+ },
+ ]);
render(null, container);
document.body.removeChild(container);
@@ -4538,21 +5117,23 @@ test("PosScreen can omit wildcard token issuance from a Quick Amount order", asy
<PosScreen
initialSubRoute="amount"
primaryCurrency="CHF"
- tokenFamilies={[{
- slug: "all-purchases",
- name: "All purchases",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "flat",
- amount: "CHF:2",
- required_tokens: 3,
- product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
- issuance: { product_selectors: "*" },
+ tokenFamilies={[
+ {
+ slug: "all-purchases",
+ name: "All purchases",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "flat",
+ amount: "CHF:2",
+ required_tokens: 3,
+ product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
+ issuance: { product_selectors: "*" },
+ },
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "quick-without-token";
@@ -4573,7 +5154,9 @@ test("PosScreen can omit wildcard token issuance from a Quick Amount order", asy
assert.ok(output);
act(() => output.click());
await act(async () => {
- (container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement).click();
+ (
+ container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement
+ ).click();
});
assert.strictEqual(submitted.order.version, undefined);
assert.strictEqual(submitted.order.amount, "CHF:5.00");
@@ -4591,24 +5174,28 @@ test("PosScreen applies wildcard redemption to a Quick Amount order", async () =
<PosScreen
initialSubRoute="amount"
primaryCurrency="CHF"
- tokenFamilies={[{
- slug: "all-purchases-20",
- name: "All purchases 20%",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "20",
- product_selectors: "*",
- required_tokens: 1,
- issuance: {
- product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
- issue_on_redemption: false,
+ tokenFamilies={[
+ {
+ slug: "all-purchases-20",
+ name: "All purchases 20%",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "20",
+ product_selectors: "*",
+ required_tokens: 1,
+ issuance: {
+ product_selectors: [
+ { type: "category", id: 1, name: "Coffee" },
+ ],
+ issue_on_redemption: false,
+ },
},
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "quick-redemption";
@@ -4629,18 +5216,22 @@ test("PosScreen applies wildcard redemption to a Quick Amount order", async () =
);
assert.match(container.textContent ?? "", /Pays CHF 8\.00 · saves CHF 2\.00/);
await act(async () => {
- (container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement).click();
+ (
+ container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement
+ ).click();
});
assert.strictEqual(submitted.order.version, 1);
assert.deepStrictEqual(
submitted.order.choices.map((choice: any) => choice.amount),
["CHF:10.00", "CHF:8"],
);
- assert.deepStrictEqual(submitted.order.choices[1].inputs, [{
- type: "token",
- token_family_slug: "all-purchases-20",
- count: 1,
- }]);
+ assert.deepStrictEqual(submitted.order.choices[1].inputs, [
+ {
+ type: "token",
+ token_family_slug: "all-purchases-20",
+ count: 1,
+ },
+ ]);
render(null, container);
document.body.removeChild(container);
@@ -4663,13 +5254,15 @@ test("PosScreen warns without blocking a charge when token rules are unavailable
/>,
container,
);
- const one = Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "1");
+ const one = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "1",
+ );
assert.ok(one);
act(() => one.click());
assert.match(container.textContent ?? "", /rules could not be evaluated/);
assert.strictEqual(
- (container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement).disabled,
+ (container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement)
+ .disabled,
false,
);
@@ -4685,21 +5278,23 @@ test("PosScreen applies token rules without an advanced-mode opt-in", async () =
<PosScreen
initialSubRoute="amount"
primaryCurrency="CHF"
- tokenFamilies={[{
- slug: "hidden-discount",
- name: "Hidden discount",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "50",
- product_selectors: "*",
- required_tokens: 1,
- issuance: { product_selectors: "*" },
+ tokenFamilies={[
+ {
+ slug: "hidden-discount",
+ name: "Hidden discount",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "50",
+ product_selectors: "*",
+ required_tokens: 1,
+ issuance: { product_selectors: "*" },
+ },
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return "automatic-token-charge";
@@ -4717,7 +5312,9 @@ test("PosScreen applies token rules without an advanced-mode opt-in", async () =
assert.match(container.textContent ?? "", /Hidden discount/);
assert.match(container.textContent ?? "", /Redeems:/);
await act(async () => {
- (container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement).click();
+ (
+ container.querySelector("#pos_charge_amount_btn") as HTMLButtonElement
+ ).click();
});
assert.strictEqual(submitted.order.amount, undefined);
assert.strictEqual(submitted.order.version, 1);
@@ -4758,19 +5355,25 @@ test("CreateOrderScreen submits account defaults as cumulative deadlines without
/>,
container,
);
- const summary = container.querySelector("#order-summary") as HTMLInputElement;
+ const summary = container.querySelector(
+ "#order-summary",
+ ) as HTMLInputElement;
act(() => {
summary.value = "Configured defaults";
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(
+ new Event("submit", { bubbles: true, cancelable: true }),
+ );
});
assert.strictEqual(submitted.order.pay_deadline.t_s, nowMs / 1000 + 2 * 60);
- assert.deepStrictEqual(submitted.refund_delay, { d_us: 5 * 60 * 1_000_000 });
+ assert.deepStrictEqual(submitted.refund_delay, {
+ d_us: 5 * 60 * 1_000_000,
+ });
assert.strictEqual(
submitted.order.wire_transfer_deadline.t_s,
nowMs / 1000 + 10 * 60,
@@ -4804,7 +5407,9 @@ test("CreateOrderScreen serializes an explicitly entered fee override", async ()
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
- (container.querySelector("#order-advanced-editing") as HTMLInputElement).click();
+ (
+ container.querySelector("#order-advanced-editing") as HTMLInputElement
+ ).click();
});
act(() => findButton(container, "Order settings")?.click());
const fee = container.querySelector("#max-fee") as HTMLInputElement;
@@ -4813,9 +5418,9 @@ test("CreateOrderScreen serializes an explicitly entered fee override", async ()
fee.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted.order.max_fee, "CHF:0.25");
@@ -4848,12 +5453,14 @@ test("CreateOrderScreen propagates an infinite refund window to later deadlines"
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.deepStrictEqual(submitted.refund_delay, { d_us: "forever" });
- assert.deepStrictEqual(submitted.order.wire_transfer_deadline, { t_s: "never" });
+ assert.deepStrictEqual(submitted.order.wire_transfer_deadline, {
+ t_s: "never",
+ });
render(null, container);
document.body.removeChild(container);
@@ -4866,24 +5473,28 @@ test("CreateOrderScreen applies all-purchase redemption without a line item", as
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- tokenFamilies={[{
- slug: "merchant-wide-20",
- name: "Merchant-wide 20%",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "20",
- product_selectors: "*",
- required_tokens: 1,
- issuance: {
- product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
- issue_on_redemption: false,
+ tokenFamilies={[
+ {
+ slug: "merchant-wide-20",
+ name: "Merchant-wide 20%",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "20",
+ product_selectors: "*",
+ required_tokens: 1,
+ issuance: {
+ product_selectors: [
+ { type: "category", id: 1, name: "Drinks" },
+ ],
+ issue_on_redemption: false,
+ },
},
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return { ok: true, orderId: "amount-only-redemption" };
@@ -4902,7 +5513,10 @@ test("CreateOrderScreen applies all-purchase redemption without a line item", as
assert.ok(container.querySelector('[role="tablist"]'));
assert.match(container.textContent ?? "", /Customer can redeem/);
assert.match(container.textContent ?? "", /1× Merchant-wide 20%/);
- assert.match(container.textContent ?? "", /Customer pays CHF 8\.00 and saves CHF 2\.00/);
+ assert.match(
+ container.textContent ?? "",
+ /Customer pays CHF 8\.00 and saves CHF 2\.00/,
+ );
assert.match(container.textContent ?? "", /All purchases qualify/);
await act(async () => {
@@ -4925,28 +5539,32 @@ test("CreateOrderScreen exposes token effects and advanced editing without an op
let submitted: any;
const commonProps = {
configPrimaryCurrency: "CHF",
- tokenFamilies: [{
- slug: "hidden-discount",
- name: "Hidden discount",
- kind: "discount" as const,
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "50",
- product_selectors: "*",
- required_tokens: 1,
- issuance: { product_selectors: "*" },
+ tokenFamilies: [
+ {
+ slug: "hidden-discount",
+ name: "Hidden discount",
+ kind: "discount" as const,
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "50",
+ product_selectors: "*",
+ required_tokens: 1,
+ issuance: { product_selectors: "*" },
+ },
},
},
- }],
- initialChoices: [{
- id: "hidden-choice",
- description: "Hidden custom choice",
- amount: "CHF:1",
- inputs: [{ slug: "hidden-discount", count: 1 }],
- outputs: [{ slug: "hidden-discount", count: 1 }],
- }],
+ ],
+ initialChoices: [
+ {
+ id: "hidden-choice",
+ description: "Hidden custom choice",
+ amount: "CHF:1",
+ inputs: [{ slug: "hidden-discount", count: 1 }],
+ outputs: [{ slug: "hidden-discount", count: 1 }],
+ },
+ ],
onCreateOrder: async (request: any) => {
submitted = request;
return { ok: true as const, orderId: "basic-order" };
@@ -4967,23 +5585,35 @@ test("CreateOrderScreen exposes token effects and advanced editing without an op
assert.strictEqual(reviewAdvanced.checked, false);
act(() => reviewAdvanced.click());
assert.strictEqual(reviewAdvanced.checked, true);
- const hiddenChoice = Array.from(container.querySelectorAll("article")).find((article) =>
- article.textContent?.includes("Hidden custom choice")) as HTMLElement;
- const editHiddenChoice = Array.from(hiddenChoice.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Edit") as HTMLButtonElement;
+ const hiddenChoice = Array.from(container.querySelectorAll("article")).find(
+ (article) => article.textContent?.includes("Hidden custom choice"),
+ ) as HTMLElement;
+ const editHiddenChoice = Array.from(
+ hiddenChoice.querySelectorAll("button"),
+ ).find(
+ (button) => button.textContent?.trim() === "Edit",
+ ) as HTMLButtonElement;
act(() => editHiddenChoice.click());
- assert.ok(Array.from(container.querySelectorAll('input[id^="choice_description_"]')).some((input) =>
- (input as HTMLInputElement).value === "Hidden custom choice"));
+ assert.ok(
+ Array.from(
+ container.querySelectorAll('input[id^="choice_description_"]'),
+ ).some(
+ (input) => (input as HTMLInputElement).value === "Hidden custom choice",
+ ),
+ );
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted.order.amount, undefined);
assert.strictEqual(submitted.order.version, 1);
- assert.ok(submitted.order.choices.some((choice: any) =>
- choice.description === "Hidden custom choice"));
+ assert.ok(
+ submitted.order.choices.some(
+ (choice: any) => choice.description === "Hidden custom choice",
+ ),
+ );
render(null, container);
document.body.removeChild(container);
@@ -4998,7 +5628,9 @@ test("CreateOrderScreen keeps creation available while token rules load or fail"
error: undefined,
isLoading: true,
isRefreshing: false,
- refresh: async () => { refreshes += 1; },
+ refresh: async () => {
+ refreshes += 1;
+ },
};
render(
<CreateOrderScreen
@@ -5010,8 +5642,11 @@ test("CreateOrderScreen keeps creation available while token rules load or fail"
);
assert.match(container.textContent ?? "", /rules are still loading/);
assert.strictEqual(
- (Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Create Order") as HTMLButtonElement).disabled,
+ (
+ Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Create Order",
+ ) as HTMLButtonElement
+ ).disabled,
false,
);
@@ -5029,8 +5664,9 @@ test("CreateOrderScreen keeps creation available while token rules load or fail"
container,
);
assert.match(container.textContent ?? "", /rules could not be evaluated/);
- const retry = Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Retry token rules");
+ const retry = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Retry token rules",
+ );
assert.ok(retry);
await act(async () => retry.click());
assert.strictEqual(refreshes, 1);
@@ -5072,8 +5708,14 @@ test("CreateOrderScreen uses complete stale token rules but never a partial set"
/>,
container,
);
- assert.match(container.textContent ?? "", /last complete rules are being used/);
- assert.match(container.textContent ?? "", /Customer can redeem1× All purchases/);
+ assert.match(
+ container.textContent ?? "",
+ /last complete rules are being used/,
+ );
+ assert.match(
+ container.textContent ?? "",
+ /Customer can redeem1× All purchases/,
+ );
render(
<CreateOrderScreen
@@ -5085,7 +5727,10 @@ test("CreateOrderScreen uses complete stale token rules but never a partial set"
container,
);
assert.match(container.textContent ?? "", /rules could not be evaluated/);
- assert.doesNotMatch(container.textContent ?? "", /Customer can redeem1× All purchases/);
+ assert.doesNotMatch(
+ container.textContent ?? "",
+ /Customer can redeem1× All purchases/,
+ );
render(null, container);
document.body.removeChild(container);
@@ -5099,16 +5744,18 @@ test("CreateOrderScreen submits full-price and generated semantic choices", asyn
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- catalogueProducts={[{
- id: "espresso",
- name: "Espresso",
- price: "CHF:10",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "Drinks",
- categories: [1],
- }]}
+ catalogueProducts={[
+ {
+ id: "espresso",
+ name: "Espresso",
+ price: "CHF:10",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "Drinks",
+ categories: [1],
+ },
+ ]}
tokenFamilies={[
{
slug: "coffee20",
@@ -5120,10 +5767,14 @@ test("CreateOrderScreen submits full-price and generated semantic choices", asyn
type: "percentage",
percentage: "20",
rounding: { mode: "nearest", precision: "0.01" },
- product_selectors: [{ type: "product", id: "espresso", name: "Espresso" }],
+ product_selectors: [
+ { type: "product", id: "espresso", name: "Espresso" },
+ ],
required_tokens: 5,
issuance: {
- product_selectors: [{ type: "product", id: "espresso", name: "Espresso" }],
+ product_selectors: [
+ { type: "product", id: "espresso", name: "Espresso" },
+ ],
issue_on_redemption: false,
},
},
@@ -5150,8 +5801,10 @@ test("CreateOrderScreen submits full-price and generated semantic choices", asyn
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
assert.ok(itemizedTab);
act(() => itemizedTab.click());
@@ -5172,10 +5825,16 @@ test("CreateOrderScreen submits full-price and generated semantic choices", asyn
const automaticPaymentToggle = container.querySelector(
'input[aria-label="Redeem Coffee club for this order"]',
) as HTMLInputElement;
- assert.ok(automaticPaymentToggle, "Simple mode must contain the automatic redemption");
+ assert.ok(
+ automaticPaymentToggle,
+ "Simple mode must contain the automatic redemption",
+ );
assert.match(container.textContent ?? "", /Customer can redeem/);
assert.match(container.textContent ?? "", /5× Coffee club/);
- assert.match(container.textContent ?? "", /Customer pays CHF 8\.00 and saves CHF 2\.00/);
+ assert.match(
+ container.textContent ?? "",
+ /Customer pays CHF 8\.00 and saves CHF 2\.00/,
+ );
assert.match(container.textContent ?? "", /matches Espresso/);
const automaticOutputToggle = container.querySelector(
@@ -5187,16 +5846,27 @@ test("CreateOrderScreen submits full-price and generated semantic choices", asyn
'input[aria-label="Earn Coffee club for this order"]',
) as HTMLInputElement;
act(() => currentAutomaticOutputToggle.click());
- const restoreButton = Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Restore automatic effects") as HTMLButtonElement;
+ const restoreButton = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Restore automatic effects",
+ ) as HTMLButtonElement;
assert.ok(restoreButton);
act(() => restoreButton.click());
- assert.strictEqual((container.querySelector(
- 'input[aria-label="Redeem Coffee club for this order"]',
- ) as HTMLInputElement).checked, true);
- assert.strictEqual((container.querySelector(
- 'input[aria-label="Earn Coffee club for this order"]',
- ) as HTMLInputElement).checked, true);
+ assert.strictEqual(
+ (
+ container.querySelector(
+ 'input[aria-label="Redeem Coffee club for this order"]',
+ ) as HTMLInputElement
+ ).checked,
+ true,
+ );
+ assert.strictEqual(
+ (
+ container.querySelector(
+ 'input[aria-label="Earn Coffee club for this order"]',
+ ) as HTMLInputElement
+ ).checked,
+ true,
+ );
const form = container.querySelector("form") as HTMLFormElement;
await act(async () => {
@@ -5228,32 +5898,36 @@ test("CreateOrderScreen computes the itemized total and only overrides it in adv
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- catalogueProducts={[{
- id: "tea",
- name: "Tea",
- price: "CHF:4",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "Drinks",
- categories: [1],
- }]}
- tokenFamilies={[{
- slug: "tea25",
- name: "Tea club",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "percentage",
- percentage: "25",
- rounding: { mode: "nearest", precision: "0.01" },
- product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
- required_tokens: 1,
- issuance: { product_selectors: "*" },
+ catalogueProducts={[
+ {
+ id: "tea",
+ name: "Tea",
+ price: "CHF:4",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "Drinks",
+ categories: [1],
+ },
+ ]}
+ tokenFamilies={[
+ {
+ slug: "tea25",
+ name: "Tea club",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "percentage",
+ percentage: "25",
+ rounding: { mode: "nearest", precision: "0.01" },
+ product_selectors: [{ type: "category", id: 1, name: "Drinks" }],
+ required_tokens: 1,
+ issuance: { product_selectors: "*" },
+ },
},
},
- }]}
+ ]}
/>,
container,
);
@@ -5264,13 +5938,16 @@ test("CreateOrderScreen computes the itemized total and only overrides it in adv
"an empty order must not start with a category-rule warning",
);
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
assert.ok(itemizedTab);
act(() => itemizedTab.click());
const productSelect = Array.from(container.querySelectorAll("select")).find(
- (select) => Array.from(select.options).some((option) => option.value === "tea"),
+ (select) =>
+ Array.from(select.options).some((option) => option.value === "tea"),
);
const addButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.trim() === "Add to Order",
@@ -5293,7 +5970,10 @@ test("CreateOrderScreen computes the itemized total and only overrides it in adv
/Some category-based token rules are not applied/,
"matching line items must not show a category-rule warning",
);
- assert.match(container.textContent ?? "", /Customer pays CHF 3\.00 and saves CHF 1\.00/);
+ assert.match(
+ container.textContent ?? "",
+ /Customer pays CHF 3\.00 and saves CHF 1\.00/,
+ );
const decreaseTea = container.querySelector(
'button[aria-label="Decrease Tea quantity"]',
) as HTMLButtonElement;
@@ -5305,7 +5985,10 @@ test("CreateOrderScreen computes the itemized total and only overrides it in adv
assert.strictEqual(decreaseTea.disabled, true);
act(() => increaseTea.click());
assert.match(container.textContent ?? "", /CHF 8\.00/);
- assert.match(container.textContent ?? "", /Customer pays CHF 6\.00 and saves CHF 2\.00/);
+ assert.match(
+ container.textContent ?? "",
+ /Customer pays CHF 6\.00 and saves CHF 2\.00/,
+ );
assert.strictEqual(decreaseTea.disabled, false);
act(() => decreaseTea.click());
assert.match(container.textContent ?? "", /CHF 4\.00/);
@@ -5320,19 +6003,27 @@ test("CreateOrderScreen computes the itemized total and only overrides it in adv
) as HTMLInputElement;
assert.ok(enableOverride);
act(() => enableOverride.click());
- const amount = container.querySelector("#itemized-amount-override") as HTMLInputElement;
+ const amount = container.querySelector(
+ "#itemized-amount-override",
+ ) as HTMLInputElement;
act(() => {
amount.value = "6";
amount.dispatchEvent(new Event("input", { bubbles: true }));
});
- assert.match(container.textContent ?? "", /The contract total is CHF 6\.00; line items total CHF 4\.00\./);
+ assert.match(
+ container.textContent ?? "",
+ /The contract total is CHF 6\.00; line items total CHF 4\.00\./,
+ );
assert.match(container.textContent ?? "", /Product selection rules excluded/);
const currentIncreaseTea = container.querySelector(
'button[aria-label="Increase Tea quantity"]',
) as HTMLButtonElement;
act(() => currentIncreaseTea.click());
- assert.match(container.textContent ?? "", /Advanced override; items total CHF 8\.00/);
+ assert.match(
+ container.textContent ?? "",
+ /Advanced override; items total CHF 8\.00/,
+ );
const disableOverride = container.querySelector(
"#itemized-override-enabled",
) as HTMLInputElement;
@@ -5376,8 +6067,10 @@ test("CreateOrderScreen edits and removes one-off line items", async () => {
summary.value = "Custom line items";
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
assert.ok(itemizedTab);
act(() => itemizedTab.click());
@@ -5406,17 +6099,15 @@ test("CreateOrderScreen edits and removes one-off line items", async () => {
assert.ok(increase);
act(() => increase.click());
assert.strictEqual(
- container.querySelector('output[aria-label="Service fee quantity"]')?.textContent,
+ container.querySelector('output[aria-label="Service fee quantity"]')
+ ?.textContent,
"2",
);
- assert.strictEqual(
- container.textContent?.includes("CHF 2.00"),
- true,
- );
+ assert.strictEqual(container.textContent?.includes("CHF 2.00"), true);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.deepStrictEqual(submitted.order.products[0], {
product_name: "Service fee",
@@ -5428,12 +6119,11 @@ test("CreateOrderScreen edits and removes one-off line items", async () => {
render(null, container);
- render(
- <CreateOrderScreen configPrimaryCurrency="CHF" />,
- container,
- );
- const itemizedTabAgain = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ render(<CreateOrderScreen configPrimaryCurrency="CHF" />, container);
+ const itemizedTabAgain = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
assert.ok(itemizedTabAgain);
act(() => itemizedTabAgain.click());
@@ -5469,39 +6159,53 @@ test("CreateOrderScreen switches line-item entry modes without losing either dra
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- catalogueProducts={[{
- id: "espresso",
- name: "Espresso",
- price: "CHF:3",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "Drinks",
- }]}
+ catalogueProducts={[
+ {
+ id: "espresso",
+ name: "Espresso",
+ price: "CHF:3",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "Drinks",
+ },
+ ]}
/>,
container,
);
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
act(() => itemizedTab.click());
- let entry = container.querySelector('[data-line-item-entry="inventory"]') as HTMLElement;
+ let entry = container.querySelector(
+ '[data-line-item-entry="inventory"]',
+ ) as HTMLElement;
assert.ok(entry);
- assert.strictEqual(entry.querySelector('input[placeholder="Item description / name"]'), null);
+ assert.strictEqual(
+ entry.querySelector('input[placeholder="Item description / name"]'),
+ null,
+ );
const product = entry.querySelector("select") as HTMLSelectElement;
- const inventoryQuantity = entry.querySelector('input[type="number"]') as HTMLInputElement;
+ const inventoryQuantity = entry.querySelector(
+ 'input[type="number"]',
+ ) as HTMLInputElement;
act(() => {
product.value = "espresso";
product.dispatchEvent(new Event("change", { bubbles: true }));
inventoryQuantity.value = "3";
inventoryQuantity.dispatchEvent(new Event("input", { bubbles: true }));
});
- const showCustom = Array.from(entry.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Add custom item") as HTMLButtonElement;
+ const showCustom = Array.from(entry.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Add custom item",
+ ) as HTMLButtonElement;
act(() => showCustom.click());
- entry = container.querySelector('[data-line-item-entry="custom"]') as HTMLElement;
+ entry = container.querySelector(
+ '[data-line-item-entry="custom"]',
+ ) as HTMLElement;
assert.ok(entry);
assert.strictEqual(entry.querySelector("select"), null);
const customName = entry.querySelector(
@@ -5516,26 +6220,49 @@ test("CreateOrderScreen switches line-item entry modes without losing either dra
customPrice.value = "7.50";
customPrice.dispatchEvent(new Event("input", { bubbles: true }));
});
- const showInventory = Array.from(entry.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Add from Inventory") as HTMLButtonElement;
+ const showInventory = Array.from(entry.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Add from Inventory",
+ ) as HTMLButtonElement;
act(() => showInventory.click());
- entry = container.querySelector('[data-line-item-entry="inventory"]') as HTMLElement;
- assert.strictEqual((entry.querySelector("select") as HTMLSelectElement).value, "espresso");
- assert.strictEqual((entry.querySelector('input[type="number"]') as HTMLInputElement).value, "3");
- const reopenCustom = Array.from(entry.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Add custom item") as HTMLButtonElement;
+ entry = container.querySelector(
+ '[data-line-item-entry="inventory"]',
+ ) as HTMLElement;
+ assert.strictEqual(
+ (entry.querySelector("select") as HTMLSelectElement).value,
+ "espresso",
+ );
+ assert.strictEqual(
+ (entry.querySelector('input[type="number"]') as HTMLInputElement).value,
+ "3",
+ );
+ const reopenCustom = Array.from(entry.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Add custom item",
+ ) as HTMLButtonElement;
act(() => reopenCustom.click());
- entry = container.querySelector('[data-line-item-entry="custom"]') as HTMLElement;
- assert.strictEqual((entry.querySelector(
- 'input[placeholder="Item description / name"]',
- ) as HTMLInputElement).value, "Table service");
- assert.strictEqual((entry.querySelector(
- 'input[placeholder="Price (e.g. 2.50)"]',
- ) as HTMLInputElement).value, "7.50");
- const addCustom = Array.from(entry.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Add One-off") as HTMLButtonElement;
+ entry = container.querySelector(
+ '[data-line-item-entry="custom"]',
+ ) as HTMLElement;
+ assert.strictEqual(
+ (
+ entry.querySelector(
+ 'input[placeholder="Item description / name"]',
+ ) as HTMLInputElement
+ ).value,
+ "Table service",
+ );
+ assert.strictEqual(
+ (
+ entry.querySelector(
+ 'input[placeholder="Price (e.g. 2.50)"]',
+ ) as HTMLInputElement
+ ).value,
+ "7.50",
+ );
+ const addCustom = Array.from(entry.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Add One-off",
+ ) as HTMLButtonElement;
act(() => addCustom.click());
assert.ok(container.querySelector('[data-line-item-entry="custom"]'));
@@ -5546,20 +6273,34 @@ test("CreateOrderScreen switches line-item entry modes without losing either dra
test("CreateOrderScreen waits for inventory before defaulting empty inventory to custom entry", () => {
const container = document.createElement("div");
document.body.appendChild(container);
- render(<CreateOrderScreen catalogueProducts={[]} catalogueProductsLoading />, container);
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ render(
+ <CreateOrderScreen catalogueProducts={[]} catalogueProductsLoading />,
+ container,
+ );
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
act(() => itemizedTab.click());
assert.ok(container.querySelector('[data-line-item-entry="inventory"]'));
- render(<CreateOrderScreen catalogueProducts={[]} catalogueProductsLoading={false} />, container);
- const entry = container.querySelector('[data-line-item-entry="custom"]') as HTMLElement;
+ render(
+ <CreateOrderScreen
+ catalogueProducts={[]}
+ catalogueProductsLoading={false}
+ />,
+ container,
+ );
+ const entry = container.querySelector(
+ '[data-line-item-entry="custom"]',
+ ) as HTMLElement;
assert.ok(entry);
assert.strictEqual(entry.querySelector("select"), null);
assert.strictEqual(
- Array.from(entry.querySelectorAll("button")).some((button) =>
- button.textContent?.trim() === "Add from Inventory"),
+ Array.from(entry.querySelectorAll("button")).some(
+ (button) => button.textContent?.trim() === "Add from Inventory",
+ ),
false,
);
@@ -5574,15 +6315,17 @@ test("CreateOrderScreen preserves both authoring drafts and submits only the act
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- catalogueProducts={[{
- id: "tea",
- name: "Tea",
- price: "CHF:4",
- stock: "",
- stockTracked: false,
- soldCount: 0,
- category: "Drinks",
- }]}
+ catalogueProducts={[
+ {
+ id: "tea",
+ name: "Tea",
+ price: "CHF:4",
+ stock: "",
+ stockTracked: false,
+ soldCount: 0,
+ category: "Drinks",
+ },
+ ]}
onCreateOrder={async (request) => {
submitted.push(request);
return { ok: true, orderId: `draft-${submitted.length}` };
@@ -5591,7 +6334,9 @@ test("CreateOrderScreen preserves both authoring drafts and submits only the act
container,
);
const summary = container.querySelector("#order-summary") as HTMLInputElement;
- const quickAmount = container.querySelector("#order-amount") as HTMLInputElement;
+ const quickAmount = container.querySelector(
+ "#order-amount",
+ ) as HTMLInputElement;
act(() => {
summary.value = "Draft switching";
summary.dispatchEvent(new Event("input", { bubbles: true }));
@@ -5599,12 +6344,15 @@ test("CreateOrderScreen preserves both authoring drafts and submits only the act
quickAmount.dispatchEvent(new Event("input", { bubbles: true }));
});
- const itemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const itemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
act(() => itemizedTab.click());
const productSelect = Array.from(container.querySelectorAll("select")).find(
- (select) => Array.from(select.options).some((option) => option.value === "tea"),
+ (select) =>
+ Array.from(select.options).some((option) => option.value === "tea"),
) as HTMLSelectElement;
act(() => {
productSelect.value = "tea";
@@ -5625,23 +6373,25 @@ test("CreateOrderScreen preserves both authoring drafts and submits only the act
"12",
);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted[0].order.amount, "CHF:12");
assert.strictEqual(submitted[0].order.products, undefined);
assert.strictEqual(submitted[0].inventory_products, undefined);
- const currentItemizedTab = Array.from(container.querySelectorAll('[role="tab"]')).find(
- (tab) => tab.textContent?.includes("Itemized order"),
+ const currentItemizedTab = Array.from(
+ container.querySelectorAll('[role="tab"]'),
+ ).find((tab) =>
+ tab.textContent?.includes("Itemized order"),
) as HTMLButtonElement;
act(() => currentItemizedTab.click());
assert.match(container.textContent ?? "", /Tea \(Inventory\)/);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted[1].order.amount, "CHF:4");
assert.strictEqual(submitted[1].order.products, undefined);
@@ -5658,21 +6408,23 @@ test("CreateOrderScreen can dismiss automatic token issuance from Simple mode",
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- tokenFamilies={[{
- slug: "loyalty",
- name: "Loyalty stamp",
- kind: "discount",
- detailsLoaded: true,
- extraData: {
- experimental_discount: {
- type: "flat",
- amount: "CHF:1",
- product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
- required_tokens: 5,
- issuance: { product_selectors: "*" },
+ tokenFamilies={[
+ {
+ slug: "loyalty",
+ name: "Loyalty stamp",
+ kind: "discount",
+ detailsLoaded: true,
+ extraData: {
+ experimental_discount: {
+ type: "flat",
+ amount: "CHF:1",
+ product_selectors: [{ type: "category", id: 1, name: "Coffee" }],
+ required_tokens: 5,
+ issuance: { product_selectors: "*" },
+ },
},
},
- }]}
+ ]}
onCreateOrder={async (request) => {
submitted = request;
return { ok: true, orderId: "without-loyalty" };
@@ -5698,9 +6450,9 @@ test("CreateOrderScreen can dismiss automatic token issuance from Simple mode",
);
assert.match(container.textContent ?? "", /Excluded from this order/);
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted.order.version, undefined);
assert.strictEqual(submitted.order.amount, "CHF:10.00");
@@ -5716,13 +6468,18 @@ test("CreateOrderScreen shows merchant-added choices in advanced editing", () =>
render(
<CreateOrderScreen
configPrimaryCurrency="CHF"
- initialChoices={[{
- id: "member-price",
- description: "Member price",
- amount: "CHF:7.50",
- inputs: [{ slug: "member", count: 2 }],
- outputs: [{ slug: "member", count: 2 }, { slug: "stamp", count: 1 }],
- }]}
+ initialChoices={[
+ {
+ id: "member-price",
+ description: "Member price",
+ amount: "CHF:7.50",
+ inputs: [{ slug: "member", count: 2 }],
+ outputs: [
+ { slug: "member", count: 2 },
+ { slug: "stamp", count: 1 },
+ ],
+ },
+ ]}
tokenFamilies={[
{ slug: "member", name: "Member card", kind: "subscription" },
{ slug: "stamp", name: "Loyalty stamp", kind: "discount" },
@@ -5737,15 +6494,20 @@ test("CreateOrderScreen shows merchant-added choices in advanced editing", () =>
) as HTMLInputElement;
assert.ok(advancedEditing);
act(() => advancedEditing.click());
- const memberChoice = Array.from(container.querySelectorAll("article")).find((article) =>
- article.textContent?.includes("Member price")) as HTMLElement;
+ const memberChoice = Array.from(container.querySelectorAll("article")).find(
+ (article) => article.textContent?.includes("Member price"),
+ ) as HTMLElement;
assert.ok(memberChoice);
- const editChoice = Array.from(memberChoice.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Edit") as HTMLButtonElement;
+ const editChoice = Array.from(memberChoice.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Edit",
+ ) as HTMLButtonElement;
assert.ok(editChoice);
act(() => editChoice.click());
- assert.ok(Array.from(container.querySelectorAll('input[id^="choice_description_"]')).some((input) =>
- (input as HTMLInputElement).value === "Member price"));
+ assert.ok(
+ Array.from(
+ container.querySelectorAll('input[id^="choice_description_"]'),
+ ).some((input) => (input as HTMLInputElement).value === "Member price"),
+ );
assert.match(container.textContent ?? "", /Customer tokens required/);
assert.match(container.textContent ?? "", /Member card/);
assert.match(container.textContent ?? "", /Loyalty stamp/);
@@ -5754,16 +6516,26 @@ test("CreateOrderScreen shows merchant-added choices in advanced editing", () =>
) as HTMLInputElement;
assert.ok(requiredCount);
assert.strictEqual(requiredCount.value, "2");
- assert.strictEqual((container.querySelector(
- 'input[aria-label="Count for issued token Loyalty stamp"]',
- ) as HTMLInputElement).value, "1");
+ assert.strictEqual(
+ (
+ container.querySelector(
+ 'input[aria-label="Count for issued token Loyalty stamp"]',
+ ) as HTMLInputElement
+ ).value,
+ "1",
+ );
act(() => {
requiredCount.value = "3";
requiredCount.dispatchEvent(new Event("input", { bubbles: true }));
});
- assert.strictEqual((container.querySelector(
- 'input[aria-label="Count for required token Member card"]',
- ) as HTMLInputElement).value, "3");
+ assert.strictEqual(
+ (
+ container.querySelector(
+ 'input[aria-label="Count for required token Member card"]',
+ ) as HTMLInputElement
+ ).value,
+ "3",
+ );
render(null, container);
document.body.removeChild(container);
@@ -5793,43 +6565,62 @@ test("CreateOrderScreen adds and edits a zero-price choice in advanced editing",
) as HTMLInputElement;
assert.ok(advancedEditing);
act(() => advancedEditing.click());
- const editor = container.querySelector("[data-order-advanced-choices]") as HTMLElement;
- const addChoice = Array.from(editor.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Add choice") as HTMLButtonElement;
+ const editor = container.querySelector(
+ "[data-order-advanced-choices]",
+ ) as HTMLElement;
+ const addChoice = Array.from(editor.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Add choice",
+ ) as HTMLButtonElement;
assert.ok(addChoice);
act(() => addChoice.click());
const cards = editor.querySelectorAll("article");
assert.strictEqual(cards.length, 2);
const added = cards[1] as HTMLElement;
- const editAdded = Array.from(added.querySelectorAll("button")).find((button) =>
- button.textContent?.trim() === "Edit") as HTMLButtonElement;
+ const editAdded = Array.from(added.querySelectorAll("button")).find(
+ (button) => button.textContent?.trim() === "Edit",
+ ) as HTMLButtonElement;
assert.ok(editAdded);
act(() => editAdded.click());
const expandedAdded = editor.querySelectorAll("article")[1] as HTMLElement;
- const description = expandedAdded.querySelector('input[id^="choice_description_"]') as HTMLInputElement;
+ const description = expandedAdded.querySelector(
+ 'input[id^="choice_description_"]',
+ ) as HTMLInputElement;
act(() => {
description.value = "Complimentary order";
description.dispatchEvent(new Event("input", { bubbles: true }));
});
- const currentAmount = expandedAdded.querySelector('input[id^="choice_amount_"]') as HTMLInputElement;
+ const currentAmount = expandedAdded.querySelector(
+ 'input[id^="choice_amount_"]',
+ ) as HTMLInputElement;
act(() => {
currentAmount.value = "0";
currentAmount.dispatchEvent(new Event("input", { bubbles: true }));
});
- assert.ok(Array.from(editor.querySelectorAll('input[id^="choice_description_"]')).some((input) =>
- (input as HTMLInputElement).value === "Complimentary order"));
+ assert.ok(
+ Array.from(
+ editor.querySelectorAll('input[id^="choice_description_"]'),
+ ).some(
+ (input) => (input as HTMLInputElement).value === "Complimentary order",
+ ),
+ );
const returnToOrder = container.querySelector(
"#order-advanced-editing",
) as HTMLInputElement;
act(() => returnToOrder.click());
- assert.strictEqual(container.querySelector("[data-order-advanced-choices]"), null);
+ assert.strictEqual(
+ container.querySelector("[data-order-advanced-choices]"),
+ null,
+ );
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
- assert.strictEqual(submitted.order.choices[1].description, "Complimentary order");
+ assert.strictEqual(
+ submitted.order.choices[1].description,
+ "Complimentary order",
+ );
assert.strictEqual(submitted.order.choices[1].amount, "CHF:0");
render(null, container);
@@ -5859,23 +6650,29 @@ test("CreateOrderScreen keeps an advanced-settings-only order on v0", async () =
"#order-advanced-editing",
) as HTMLInputElement;
act(() => advancedEditing.click());
- const settings = Array.from(container.querySelectorAll("button")).find((button) =>
- button.textContent?.includes("Order settings")) as HTMLButtonElement;
+ const settings = Array.from(container.querySelectorAll("button")).find(
+ (button) => button.textContent?.includes("Order settings"),
+ ) as HTMLButtonElement;
act(() => settings.click());
- const fulfillment = container.querySelector("#order-fulfillment") as HTMLInputElement;
+ const fulfillment = container.querySelector(
+ "#order-fulfillment",
+ ) as HTMLInputElement;
act(() => {
fulfillment.value = "https://example.com/receipt";
fulfillment.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted.order.version, undefined);
assert.strictEqual(submitted.order.amount, "CHF:10.00");
assert.strictEqual(submitted.order.choices, undefined);
- assert.strictEqual(submitted.order.fulfillment_url, "https://example.com/receipt");
+ assert.strictEqual(
+ submitted.order.fulfillment_url,
+ "https://example.com/receipt",
+ );
render(null, container);
document.body.removeChild(container);
@@ -5931,7 +6728,9 @@ test("CreateOrderScreen puts the selected usable payout target and token flag at
summary.dispatchEvent(new Event("input", { bubbles: true }));
});
act(() => {
- (container.querySelector("#order-advanced-editing") as HTMLInputElement).click();
+ (
+ container.querySelector("#order-advanced-editing") as HTMLInputElement
+ ).click();
});
const settings = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("Order settings"),
@@ -5939,7 +6738,9 @@ test("CreateOrderScreen puts the selected usable payout target and token flag at
assert.ok(settings);
act(() => settings.click());
- const target = container.querySelector("#payment-target") as HTMLSelectElement;
+ const target = container.querySelector(
+ "#payment-target",
+ ) as HTMLSelectElement;
assert.deepStrictEqual(
Array.from(target.options, (option) => option.value),
["", "h-ready"],
@@ -5950,15 +6751,17 @@ test("CreateOrderScreen puts the selected usable payout target and token flag at
});
const protect = Array.from(
container.querySelectorAll('input[type="checkbox"]'),
- ).find((input) => input.parentElement?.textContent?.includes("Protect Order ID")) as HTMLInputElement;
+ ).find((input) =>
+ input.parentElement?.textContent?.includes("Protect Order ID"),
+ ) as HTMLInputElement;
assert.ok(protect);
assert.strictEqual(protect.checked, true);
act(() => protect.click());
await act(async () => {
- container.querySelector("form")?.dispatchEvent(
- new Event("submit", { bubbles: true, cancelable: true }),
- );
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
});
assert.strictEqual(submitted.payment_target, "h-ready");
assert.strictEqual(submitted.create_token, false);
@@ -6151,10 +6954,13 @@ test("the tutorial sidebar groups chapters into an accordion", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
let selectedPath = "";
- const staticLocation = () => [
- "/tutorial/01-what-this-is",
- (path: string) => { selectedPath = path; },
- ] as const;
+ const staticLocation = () =>
+ [
+ "/tutorial/01-what-this-is",
+ (path: string) => {
+ selectedPath = path;
+ },
+ ] as const;
render(
<Router hook={staticLocation as never}>
@@ -6675,6 +7481,60 @@ test("the managed-account form normalizes IDs and omits timing overrides by defa
document.body.removeChild(container);
});
+test("the managed-account form validates its MFA phone", async () => {
+ const container = document.createElement("div");
+ document.body.appendChild(container);
+ let saves = 0;
+ render(
+ <AdminAccountFormScreen
+ mode="edit"
+ initial={{
+ id: "shop",
+ name: "Example Shop",
+ phone_number: "+41 78 123 45 67",
+ address: {},
+ jurisdiction: {},
+ use_stefan: true,
+ }}
+ phoneRegex={String.raw`^\+4179[0-9]+$`}
+ onBack={() => undefined}
+ onSubmit={async () => {
+ saves += 1;
+ }}
+ />,
+ container,
+ );
+
+ const input = container.querySelector("#managed-phone") as HTMLInputElement;
+ assert.strictEqual(input.getAttribute("aria-invalid"), "true");
+ await act(async () => {
+ input
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(saves, 0);
+
+ await act(async () => {
+ input.value = "+41 79 123 45 67";
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ const validInput = container.querySelector(
+ "#managed-phone",
+ ) as HTMLInputElement;
+ assert.strictEqual(validInput.getAttribute("aria-invalid"), "false");
+ await act(async () => {
+ validInput
+ .closest("form")!
+ .dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ assert.strictEqual(saves, 1);
+
+ render(null, container);
+ document.body.removeChild(container);
+});
+
test("the managed-account form opens an invalid saved logo for repair", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
@@ -6701,7 +7561,10 @@ test("the managed-account form opens an invalid saved logo for repair", async ()
assert.ok(container.querySelector("#managed-logo"));
assert.match(container.textContent ?? "", /saved image cannot be displayed/i);
- assert.strictEqual(findButton(container, "Create merchant account")?.disabled, true);
+ assert.strictEqual(
+ findButton(container, "Create merchant account")?.disabled,
+ true,
+ );
render(null, container);
document.body.removeChild(container);
@@ -6961,18 +7824,23 @@ test("CreateTemplateScreen never synthesizes an edit from list summary data", ()
render(
<CreateTemplateScreen
editId="summary-only"
- templates={[{
- id: "summary-only",
- name: "Summary only",
- sellsText: "Unknown",
- type: "fixed",
- }]}
+ templates={[
+ {
+ id: "summary-only",
+ name: "Summary only",
+ sellsText: "Unknown",
+ type: "fixed",
+ },
+ ]}
templateResource={failed}
/>,
container,
);
- assert.match(container.textContent ?? "", /Template details could not be loaded/);
+ assert.match(
+ container.textContent ?? "",
+ /Template details could not be loaded/,
+ );
assert.strictEqual(container.querySelector("form"), null);
render(null, container);
@@ -7913,10 +8781,18 @@ test("KYC transfer instructions emphasize the complete localized field name", ()
document.body.appendChild(container);
render(
<KycAuthInstructionsScreen
- sampleWireInstructions={[{
- target_payto: CH_ESCROW,
- subject: { type: "SIMPLE", subject: "KYC:EMPHASIS", credit_amount: "CHF:0.01" },
- }] as any}
+ sampleWireInstructions={
+ [
+ {
+ target_payto: CH_ESCROW,
+ subject: {
+ type: "SIMPLE",
+ subject: "KYC:EMPHASIS",
+ credit_amount: "CHF:0.01",
+ },
+ },
+ ] as any
+ }
/>,
container,
);
@@ -7976,16 +8852,27 @@ test("KYC wire alternatives use receiver-account tabs", async () => {
document.body.appendChild(container);
render(
<KycAuthInstructionsScreen
- sampleWireInstructions={[
- {
- target_payto: CH_ESCROW,
- subject: { type: "SIMPLE", subject: "KYC:FIRST", credit_amount: "CHF:0.01" },
- },
- {
- target_payto: "payto://iban/DE89370400440532013000?receiver-name=Second",
- subject: { type: "SIMPLE", subject: "KYC:SECOND", credit_amount: "EUR:0.02" },
- },
- ] as any}
+ sampleWireInstructions={
+ [
+ {
+ target_payto: CH_ESCROW,
+ subject: {
+ type: "SIMPLE",
+ subject: "KYC:FIRST",
+ credit_amount: "CHF:0.01",
+ },
+ },
+ {
+ target_payto:
+ "payto://iban/DE89370400440532013000?receiver-name=Second",
+ subject: {
+ type: "SIMPLE",
+ subject: "KYC:SECOND",
+ credit_amount: "EUR:0.02",
+ },
+ },
+ ] as any
+ }
senderAccount="payto://iban/CH4431999123000889012"
/>,
container,
@@ -8039,7 +8926,9 @@ test("KYC consent stays hidden until the captured terms version is refreshed", a
document.body.appendChild(container);
let acceptedVersion: string | undefined;
let finishRefresh!: () => void;
- const refreshed = new Promise<void>((resolve) => { finishRefresh = resolve; });
+ const refreshed = new Promise<void>((resolve) => {
+ finishRefresh = resolve;
+ });
const instruction = {
target_payto: CH_ESCROW,
subject: {
@@ -8115,7 +9004,8 @@ test("KYC consent stays hidden until the captured terms version is refreshed", a
);
assert.doesNotMatch(container.textContent ?? "", /KYC:VERSION-BOUND/);
assert.equal(
- container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.disabled,
+ container.querySelector<HTMLInputElement>('input[type="checkbox"]')
+ ?.disabled,
true,
);
@@ -8133,7 +9023,8 @@ test("KYC consent stays hidden until the captured terms version is refreshed", a
);
assert.doesNotMatch(container.textContent ?? "", /KYC:VERSION-BOUND/);
assert.equal(
- container.querySelector<HTMLInputElement>('input[type="checkbox"]')?.disabled,
+ container.querySelector<HTMLInputElement>('input[type="checkbox"]')
+ ?.disabled,
true,
);
@@ -8150,16 +9041,22 @@ test("KYC instructions skip consent when the exchange has no terms", async () =>
hWire="WIRE-1"
exchangeUrl="https://exchange.example/"
kycSwapTosAcceptance
- sampleWireInstructions={[{
- target_payto: CH_ESCROW,
- subject: {
- type: "SIMPLE",
- subject: "KYC:NO-TERMS",
- credit_amount: "CHF:0.01",
- },
- }] as any}
+ sampleWireInstructions={
+ [
+ {
+ target_payto: CH_ESCROW,
+ subject: {
+ type: "SIMPLE",
+ subject: "KYC:NO-TERMS",
+ credit_amount: "CHF:0.01",
+ },
+ },
+ ] as any
+ }
onLoadTerms={async () => ({ type: "not-required" })}
- onAcceptTos={async () => { accepted = true; }}
+ onAcceptTos={async () => {
+ accepted = true;
+ }}
/>,
container,
);
diff --git a/packages/taler-merchant-webui/src/utils/phone.test.ts b/packages/taler-merchant-webui/src/utils/phone.test.ts
@@ -0,0 +1,72 @@
+/*
+ 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 {
+ browserPhoneRegex,
+ isValidMfaPhone,
+ normalizeMfaPhone,
+} from "./phone.js";
+
+const SWISS_MOBILE = String.raw`^\+41 ?7[05-9]( ?[0-9]{3})( ?[0-9]{2})( ?[0-9]{2})$`;
+
+test("MFA phone normalization mirrors the merchant backend", () => {
+ assert.strictEqual(normalizeMfaPhone("+41 79 123 45 67"), "+41791234567");
+ assert.strictEqual(normalizeMfaPhone("+1 (202) 555-0173"), "+12025550173");
+ assert.strictEqual(normalizeMfaPhone("+44.20.7946.0958"), "+442079460958");
+
+ for (const invalid of [
+ "",
+ "+1",
+ "41791234567",
+ " +41791234567",
+ "+41791234567 ",
+ "+41 79 123 45 67",
+ "+41-79-123-45-67-extra",
+ `+${"1".repeat(30)}`,
+ ]) {
+ assert.strictEqual(normalizeMfaPhone(invalid), undefined, invalid);
+ }
+});
+
+test("the deployed Swiss phone policy is browser-compatible", () => {
+ assert.ok(browserPhoneRegex(SWISS_MOBILE).regex);
+ assert.strictEqual(isValidMfaPhone("+41 79 123 45 67", SWISS_MOBILE), true);
+ assert.strictEqual(isValidMfaPhone("+41-79-123-45-67", SWISS_MOBILE), true);
+ assert.strictEqual(isValidMfaPhone("+41 74 123 45 67", SWISS_MOBILE), false);
+ assert.strictEqual(isValidMfaPhone("", SWISS_MOBILE), true);
+});
+
+test("POSIX and JavaScript-only extensions are not treated as portable", () => {
+ assert.match(browserPhoneRegex("^[[:digit:]]+$").error ?? "", /POSIX/);
+ assert.match(
+ browserPhoneRegex(String.raw`^(.)\1$`).error ?? "",
+ /back-reference/,
+ );
+ assert.match(browserPhoneRegex(String.raw`^\d+$`).error ?? "", /escape/);
+ assert.match(browserPhoneRegex("^(?=x)x$").error ?? "", /JavaScript-only/);
+ assert.ok(browserPhoneRegex("[").error);
+});
+
+test("an unsupported policy fails open and logs one console error", () => {
+ const pattern = "^[[:digit:]]{8,19}$";
+ const calls: unknown[][] = [];
+ const previous = console.error;
+ console.error = (...args: unknown[]) => calls.push(args);
+ try {
+ assert.strictEqual(isValidMfaPhone("+41791234567", pattern), true);
+ assert.strictEqual(isValidMfaPhone("+41791234567", pattern), true);
+ } finally {
+ console.error = previous;
+ }
+ assert.strictEqual(calls.length, 1);
+ assert.match(String(calls[0]?.[0]), /deferring to the backend/);
+ assert.strictEqual(calls[0]?.[1], pattern);
+});
diff --git a/packages/taler-merchant-webui/src/utils/phone.ts b/packages/taler-merchant-webui/src/utils/phone.ts
@@ -0,0 +1,119 @@
+/*
+ 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.
+*/
+
+/**
+ * The merchant backend first validates this display form, then removes its
+ * separators before applying PHONE_REGEX. Keep this expression in lockstep
+ * with TALER_MERCHANT_phone_validate_normalize(..., false).
+ */
+const PHONE_DISPLAY_PATTERN =
+ /^\+[0-9]{1,3}[-. ]?(?:\([0-9]{1,4}\)[-. ]?)?[0-9](?:[-. ]?[0-9])*$/;
+
+const reportedUnsupportedPatterns = new Set<string>();
+
+export interface BrowserPhoneRegex {
+ regex?: RegExp;
+ error?: string;
+}
+
+/**
+ * Check for constructs whose POSIX and JavaScript meanings differ, before
+ * asking JavaScript to compile a backend-supplied POSIX ERE.
+ */
+export function browserPhoneRegex(pattern: string): BrowserPhoneRegex {
+ if (/\[\[(?::|\.|=)/.test(pattern)) {
+ return {
+ error:
+ "POSIX named, collating, and equivalence classes are not supported",
+ };
+ }
+
+ let inBracketExpression = false;
+ for (let index = 0; index < pattern.length; index += 1) {
+ const char = pattern[index]!;
+ if (char === "\\") {
+ const escaped = pattern[index + 1];
+ if (escaped === undefined) {
+ return { error: "the expression ends with an incomplete escape" };
+ }
+ if (/[A-Za-z0-9]/.test(escaped)) {
+ return {
+ error: /[0-9]/.test(escaped)
+ ? "back-references are not supported"
+ : "GNU and JavaScript character escapes are not supported",
+ };
+ }
+ index += 1;
+ continue;
+ }
+ if (char === "[" && !inBracketExpression) {
+ inBracketExpression = true;
+ continue;
+ }
+ if (char === "]" && inBracketExpression) {
+ inBracketExpression = false;
+ continue;
+ }
+ if (!inBracketExpression && char === "(" && pattern[index + 1] === "?") {
+ return { error: "JavaScript-only group extensions are not supported" };
+ }
+ }
+
+ try {
+ return { regex: new RegExp(pattern) };
+ } catch (cause) {
+ return {
+ error:
+ cause instanceof Error
+ ? cause.message
+ : "JavaScript could not compile the expression",
+ };
+ }
+}
+
+function reportUnsupportedPhoneRegex(pattern: string, reason: string): void {
+ if (reportedUnsupportedPatterns.has(pattern)) return;
+ reportedUnsupportedPatterns.add(pattern);
+ console.error(
+ `Merchant PHONE_REGEX cannot be enforced in this browser; deferring to the backend: ${reason}`,
+ pattern,
+ );
+}
+
+/** Return the canonical form against which the backend applies PHONE_REGEX. */
+export function normalizeMfaPhone(value: string): string | undefined {
+ if (!value || value.length > 30 || !PHONE_DISPLAY_PATTERN.test(value)) {
+ return undefined;
+ }
+ return `+${value.replace(/[^0-9]/g, "")}`;
+}
+
+/**
+ * Validate a private phone number used for account recovery or MFA.
+ *
+ * An empty number is valid here because whether the field is mandatory is a
+ * separate deployment policy. Unsupported POSIX expressions fail open after
+ * the backend-independent phone syntax has been checked.
+ */
+export function isValidMfaPhone(value: string, pattern?: string): boolean {
+ if (!value) return true;
+ const normalized = normalizeMfaPhone(value);
+ if (!normalized) return false;
+ if (!pattern) return true;
+
+ const compiled = browserPhoneRegex(pattern);
+ if (!compiled.regex) {
+ reportUnsupportedPhoneRegex(
+ pattern,
+ compiled.error ?? "unsupported expression",
+ );
+ return true;
+ }
+ return compiled.regex.test(normalized);
+}