taler-typescript-core

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

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:
Apackages/taler-merchant-webui/src/api/hooks/useMerchantConfig.test.ts | 32++++++++++++++++++++++++++++++++
Mpackages/taler-merchant-webui/src/api/hooks/useMerchantConfig.ts | 50++++++++++++++++++++++++++++----------------------
Mpackages/taler-merchant-webui/src/routes/AdminAccountsRoutes.tsx | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++------------
Mpackages/taler-merchant-webui/src/routes/BusinessSettingsRoute.tsx | 11++++++++---
Mpackages/taler-merchant-webui/src/routes/SelfProvisionRoute.tsx | 79++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-merchant-webui/src/routes/selfProvision.test.ts | 117++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------
Mpackages/taler-merchant-webui/src/screens/AdminAccountFormScreen.tsx | 370+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mpackages/taler-merchant-webui/src/screens/BusinessSettingsScreen.tsx | 562++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mpackages/taler-merchant-webui/src/screens/SelfProvisionScreen.tsx | 352++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mpackages/taler-merchant-webui/src/screens/screens.test.tsx | 2531+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Apackages/taler-merchant-webui/src/utils/phone.test.ts | 72++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/utils/phone.ts | 119+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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); +}