taler-typescript-core

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

commit 7bfd650ff5c4dcd0069349ba0ad6593a12134932
parent 9dc1a6440ccc74097cd72c28bf44740f2419459d
Author: Florian Dold <dold@taler.net>
Date:   Tue,  3 Nov 2026 10:14:12 +0100

merchant-webui: restore OTP setup after device creation

Show the provisioning QR code and shared secret after a device is
created, keeping setup available until the merchant leaves the screen.
Retain standard OTP provisioning for both methods and explain the
separate hardware configuration needed for price-aware devices.

Issue: https://bugs.taler.net/n/11852

Diffstat:
Mpackages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx | 46+++++++++++++++++++++++++++++++++++++++-------
Apackages/taler-merchant-webui/src/screens/DeviceSetupScreen.test.tsx | 247+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Apackages/taler-merchant-webui/src/screens/DeviceSetupScreen.tsx | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 409 insertions(+), 7 deletions(-)

diff --git a/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx b/packages/taler-merchant-webui/src/screens/CreateDeviceScreen.tsx @@ -15,9 +15,13 @@ */ import type { VNode } from "preact"; -import { session } from "../stores/session.js"; -import { useState } from "preact/hooks"; +import { currentBackendUrl, session } from "../stores/session.js"; +import { useRef, useState } from "preact/hooks"; import { useLocation } from "wouter-preact"; +import { + DeviceSetupScreen, + type CreatedDeviceSetup, +} from "./DeviceSetupScreen.js"; import { Header } from "../ui/Header.js"; import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; import type { OtpDeviceItemHook } from "../api/hooks.js"; @@ -36,7 +40,7 @@ import { ReadErrorBanner } from "../ui/ReadErrorBanner.js"; export interface CreateDeviceScreenProps { editId?: string; authenticators?: DeviceItem[]; - onSave?: (device: DeviceItem) => void; + onSave?: (device: DeviceItem) => void | Promise<void>; /** Existing devices, so an edit can find the one being changed. */ devices?: OtpDeviceItemHook[]; devicesResource?: RemoteResource<OtpDeviceItemHook[]>; @@ -143,7 +147,7 @@ export function CreateDeviceScreen(props: CreateDeviceScreenProps): VNode { // A different account, entity, or create/edit mode starts a fresh draft. const formKey = JSON.stringify([ - session.value.backendBaseUrl, + currentBackendUrl.value, session.value.account, editId ?? null, existingDevice?.id ?? null, @@ -162,6 +166,8 @@ function CreateDeviceForm({ }: CreateDeviceScreenProps): VNode { const { t } = useTranslation(); const [, setLocation] = useLocation(); + const [created, setCreated] = useState<CreatedDeviceSetup | null>(null); + const submitting = useRef(false); const [isSubmitting, setIsSubmitting] = useState<boolean>(false); const [errorMsg, setErrorMsg] = useState<string>(""); @@ -206,7 +212,7 @@ function CreateDeviceForm({ const handleSubmit = async (e: Event) => { e.preventDefault(); - if (!name.trim()) return; + if (submitting.current || created || !name.trim()) return; setErrorMsg(""); const cleanKey = isEdit ? undefined : normalizeRfc3548Base32Key(secretKey); @@ -216,7 +222,9 @@ function CreateDeviceForm({ ); return; } + submitting.current = true; setIsSubmitting(true); + const backendUrl = currentBackendUrl.value; try { const finalId = deviceId || `otp_${slugify(name)}`; @@ -229,7 +237,7 @@ function CreateDeviceForm({ }; if (onSave) { - onSave(savedDevice); + await onSave(savedDevice); } else if (editingId && onUpdateDevice) { await onUpdateDevice(editingId, { name, @@ -243,7 +251,18 @@ function CreateDeviceForm({ algorithm: method, }); } - setLocation("/authenticators"); + if (isEdit) { + setLocation("/authenticators"); + } else { + setSecretKey(""); + setCreated({ + id: finalId, + name, + algorithm: method, + secretKey: cleanKey!, + backendUrl, + }); + } } catch (err: unknown) { setErrorMsg( formatErrorMessage( @@ -253,10 +272,23 @@ function CreateDeviceForm({ ), ); } finally { + submitting.current = false; setIsSubmitting(false); } }; + if (created) { + return ( + <DeviceSetupScreen + device={created} + onDone={() => { + setCreated(null); + setLocation("/authenticators"); + }} + /> + ); + } + return ( <div class="space-y-6 max-w-3xl"> <Header diff --git a/packages/taler-merchant-webui/src/screens/DeviceSetupScreen.test.tsx b/packages/taler-merchant-webui/src/screens/DeviceSetupScreen.test.tsx @@ -0,0 +1,247 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import type { AccessToken } from "@gnu-taler/taler-util"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { render } from "preact"; +import { act } from "preact/test-utils"; +import { Router } from "wouter-preact"; +import { + CreateDeviceScreen, + type CreateDeviceScreenProps, +} from "./CreateDeviceScreen.js"; +import { deviceProvisioningUri } from "./DeviceSetupScreen.js"; +import { generateQrDataUrl } from "../ui/TalerQrCode.js"; +import { session } from "../stores/session.js"; + +const key = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +test("provisioning URI preserves escaped device and issuer values", () => { + const uri = new URL( + deviceProvisioningUri({ + id: "counter /?&é", + name: "Counter", + algorithm: 2, + secretKey: key, + backendUrl: "https://backend.example/shop/?a=b&c=d", + }), + ); + assert.equal(uri.protocol, "otpauth:"); + assert.equal(uri.hostname, "totp"); + assert.equal(decodeURIComponent(uri.pathname.slice(1)), "counter /?&é"); + assert.deepEqual(Object.fromEntries(uri.searchParams), { + issuer: "backend.example/shop/?a=b&c=d", + algorithm: "SHA1", + digits: "8", + period: "30", + secret: key, + }); +}); + +for (const algorithm of [1, 2]) { + test(`creation ${algorithm} shows the submitted secret only after save and Done dismisses it`, async () => { + const originalSession = session.value; + session.value = { + ...originalSession, + token: "secret-token:test" as AccessToken, + backendBaseUrl: "https://backend.example/", + account: "shop", + }; + const container = document.createElement("div"); + document.body.appendChild(container); + const navigation: string[] = []; + const hook = () => + ["/authenticators/new", (path: string) => navigation.push(path)] as const; + let resolveSave!: () => void; + let submitted: any; + let calls = 0; + const onAddDevice: CreateDeviceScreenProps["onAddDevice"] = async ( + device, + ) => { + calls++; + submitted = device; + await new Promise<void>((resolve) => { + resolveSave = resolve; + }); + }; + const show = () => + render( + <Router hook={hook as never}> + <CreateDeviceScreen onAddDevice={onAddDevice} /> + </Router>, + container, + ); + const input = (selector: string, value: string) => + act(() => { + const node = container.querySelector<HTMLInputElement>(selector)!; + node.value = value; + node.dispatchEvent(new Event("input", { bubbles: true })); + }); + const button = (text: string) => + Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent === text, + )!; + try { + act(show); + input("#auth_name", "Counter"); + if (algorithm === 2) { + act(() => + container.querySelector<HTMLInputElement>("#auth_method_2")!.click(), + ); + act(() => button("Enter it myself").click()); + input( + "#auth_secret_custom", + " abcd efgh ijkl mnop qrst uvwx yz23 4567 ", + ); + } + const expectedKey = + algorithm === 2 + ? key + : container.querySelector<HTMLInputElement>("#auth_secret_gen")! + .value; + const form = container.querySelector("form")!; + await act(async () => { + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + form.dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + }); + assert.equal(calls, 1); + assert.ok(!container.querySelector("[data-taler-qr-code]")); + assert.deepEqual(navigation, []); + // Edits made while the request is pending must not change setup data. + input("#auth_name", "Changed while saving"); + await act(async () => { + resolveSave(); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + assert.ok(!container.querySelector("form"), "form is replaced by setup"); + assert.match(container.textContent!, /Offline payment device created/); + assert.doesNotMatch(container.textContent!, /Changed while saving/); + assert.equal( + container.querySelector<HTMLInputElement>("#created_device_secret")! + .value, + expectedKey, + ); + assert.equal(submitted.algorithm, algorithm); + const qr = generateQrDataUrl( + deviceProvisioningUri({ + ...submitted, + backendUrl: "https://backend.example/", + }), + ); + assert.equal(qr.type, "success"); + if (qr.type === "success") + assert.equal( + container.querySelector("svg image")?.getAttribute("href") === qr.url, + true, + "QR encodes the submitted provisioning URI", + ); + assert.equal( + container.textContent!.includes( + "configure the price-aware settings separately", + ), + algorithm === 2, + ); + await act(async () => button("Copy key").click()); + assert.equal(await navigator.clipboard.readText(), expectedKey); + assert.deepEqual(navigation, []); + act(() => button("Done").click()); + assert.deepEqual(navigation, ["/authenticators"]); + assert.ok(!container.querySelector("#created_device_secret")); + assert.equal(calls, 1); + } finally { + render(null, container); + container.remove(); + session.value = originalSession; + } + }); +} + +test("failed creation preserves the draft; async onSave success resets on account/backend changes", async () => { + const originalSession = session.value; + session.value = { + ...originalSession, + token: "secret-token:test" as AccessToken, + backendBaseUrl: "https://backend.example/", + account: "shop", + }; + const container = document.createElement("div"); + document.body.appendChild(container); + let fail = true; + const save: CreateDeviceScreenProps["onSave"] = async () => { + if (fail) throw new Error("Device could not be saved"); + }; + const show = () => render(<CreateDeviceScreen onSave={save} />, container); + const submit = async () => + act(async () => { + container + .querySelector("form")! + .dispatchEvent( + new Event("submit", { bubbles: true, cancelable: true }), + ); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + const name = () => + act(() => { + const input = container.querySelector<HTMLInputElement>("#auth_name")!; + input.value = "Counter"; + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + try { + act(show); + name(); + const secret = + container.querySelector<HTMLInputElement>("#auth_secret_gen")!.value; + await submit(); + assert.match(container.textContent!, /Device could not be saved/); + assert.ok(!container.querySelector("[data-taler-qr-code]")); + assert.equal( + container.querySelector<HTMLInputElement>("#auth_secret_gen")!.value, + secret, + ); + fail = false; + await submit(); + assert.ok(container.querySelector("#created_device_secret")); + for (const change of [ + { account: "other" }, + { backendBaseUrl: "https://other.example/" }, + ]) { + act(() => { + session.value = { ...session.value, ...change }; + show(); + }); + assert.ok(!container.querySelector("#created_device_secret")); + assert.equal( + container.querySelector<HTMLInputElement>("#auth_name")!.value, + "", + ); + name(); + await submit(); + assert.ok(container.querySelector("#created_device_secret")); + } + act(() => render(null, container)); + act(show); + assert.ok(!container.querySelector("#created_device_secret")); + } finally { + render(null, container); + container.remove(); + session.value = originalSession; + } +}); diff --git a/packages/taler-merchant-webui/src/screens/DeviceSetupScreen.tsx b/packages/taler-merchant-webui/src/screens/DeviceSetupScreen.tsx @@ -0,0 +1,123 @@ +/* + 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. + + GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY + WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR + A PARTICULAR PURPOSE. See the GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along with + GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> + */ + +import type { VNode } from "preact"; +import { useTranslation } from "../context/translation.js"; +import { Header } from "../ui/Header.js"; +import { TalerQrCode } from "../ui/TalerQrCode.js"; +import { ApiErrorBanner } from "../ui/ApiErrorBanner.js"; +import { useClipboard } from "../utils/useClipboard.js"; + +/** Kept only in the creation form's memory until setup is dismissed. */ +export interface CreatedDeviceSetup { + id: string; + name: string; + algorithm: number; + secretKey: string; + backendUrl: string; +} + +export function deviceProvisioningUri(device: CreatedDeviceSetup): string { + const params = new URLSearchParams(); + params.set("issuer", device.backendUrl.replace(/^https?:\/\//i, "")); + params.set("algorithm", "SHA1"); + params.set("digits", "8"); + params.set("period", "30"); + params.set("secret", device.secretKey); + return `otpauth://totp/${encodeURIComponent(device.id)}?${params}`; +} + +export function DeviceSetupScreen({ + device, + onDone, +}: { + device: CreatedDeviceSetup; + onDone: () => void; +}): VNode { + const { t } = useTranslation(); + const { copied, error, copy } = useClipboard(); + return ( + <div class="space-y-6 max-w-3xl"> + <Header + title={t`Offline payment device created`} + onBack={onDone} + backLabel={t`Offline payment devices`} + /> + <div class="bg-white p-6 rounded-xl border border-gray-200 shadow-xs space-y-4"> + <p>{t`Scan the QR code or save the shared secret before leaving this screen. This setup information cannot be retrieved afterward.`}</p> + <dl class="space-y-2 text-sm"> + <div> + <dt class="font-semibold">{t`Device ID`}</dt> + <dd class="break-all">{device.id}</dd> + </div> + <div> + <dt class="font-semibold">{t`Description`}</dt> + <dd class="break-words">{device.name}</dd> + </div> + <div> + <dt class="font-semibold">{t`Verification method`}</dt> + <dd> + {device.algorithm === 2 + ? t`Time-based code, covering the price` + : t`Time-based code`} + </dd> + </div> + </dl> + {device.algorithm === 2 && ( + <p class="text-sm text-amber-900 bg-amber-50 p-3 rounded-lg">{t`This method requires compatible hardware. The QR code transfers the secret and standard OTP parameters; configure the price-aware settings separately on your device.`}</p> + )} + <div class="flex justify-center"> + <TalerQrCode + url={deviceProvisioningUri(device)} + variant="plain" + size={320} + alt={t`OTP setup QR code`} + /> + </div> + {error && <ApiErrorBanner error={error} />} + <div class="space-y-2"> + <label + htmlFor="created_device_secret" + class="block text-sm font-semibold" + >{t`Shared Secret Key`}</label> + <div class="flex flex-wrap gap-2"> + <input + id="created_device_secret" + type="text" + readOnly + value={device.secretKey} + class="min-w-0 flex-1 w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm" + /> + <button + type="button" + onClick={() => void copy(device.secretKey)} + class="px-4 py-2 text-sm font-semibold text-blue-700 bg-blue-50 rounded-lg" + > + {copied ? t`✓ Copied` : t`Copy key`} + </button> + </div> + </div> + <div class="flex justify-end border-t border-gray-100 pt-4"> + <button + type="button" + onClick={onDone} + class="px-6 py-2.5 text-sm font-bold text-white bg-blue-600 hover:bg-blue-700 rounded-lg" + >{t`Done`}</button> + </div> + </div> + </div> + ); +}