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:
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>
+ );
+}