From fcb25778e1f880f08eb5b961461b9234d8760ffe Mon Sep 17 00:00:00 2001
From: hsiegeln <37154749+hsiegeln@users.noreply.github.com>
Date: Tue, 28 Apr 2026 18:34:52 +0200
Subject: [PATCH] fix(sign-in): TOTP enrollment QR branding and verification
failure
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Two bugs in the sign-in UI's TOTP MFA enrollment flow:
1. Auth app displayed the PC hostname and "Platform Owner" instead of
"Cameleer" and the user's email. The sign-in UI was rendering Logto's
pre-generated QR code which uses the ENDPOINT hostname as issuer.
Now generates our own otpauth:// URI with proper branding, rendered
client-side via qrcode.react.
2. TOTP code verification returned 400 "Invalid TOTP code". The
verifyTotpSetup() call was missing the required verificationId
parameter — Logto's Experience API needs it to locate the pending
secret during enrollment.
Co-Authored-By: Claude Opus 4.6 (1M context)
---
ui/sign-in/package-lock.json | 10 ++++++++++
ui/sign-in/package.json | 1 +
ui/sign-in/src/SignInPage.tsx | 12 ++++++++----
ui/sign-in/src/experience-api.ts | 4 ++--
4 files changed, 21 insertions(+), 6 deletions(-)
diff --git a/ui/sign-in/package-lock.json b/ui/sign-in/package-lock.json
index f9b7c11..4f2bae1 100644
--- a/ui/sign-in/package-lock.json
+++ b/ui/sign-in/package-lock.json
@@ -10,6 +10,7 @@
"dependencies": {
"@cameleer/design-system": "^0.1.54",
"@simplewebauthn/browser": "^13.3.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
@@ -1905,6 +1906,15 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/qrcode.react": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
+ "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/react": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
diff --git a/ui/sign-in/package.json b/ui/sign-in/package.json
index f28d3bd..96b3a54 100644
--- a/ui/sign-in/package.json
+++ b/ui/sign-in/package.json
@@ -11,6 +11,7 @@
"dependencies": {
"@cameleer/design-system": "^0.1.54",
"@simplewebauthn/browser": "^13.3.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
diff --git a/ui/sign-in/src/SignInPage.tsx b/ui/sign-in/src/SignInPage.tsx
index f8703d9..3713fc7 100644
--- a/ui/sign-in/src/SignInPage.tsx
+++ b/ui/sign-in/src/SignInPage.tsx
@@ -2,6 +2,7 @@ import { type FormEvent, useEffect, useMemo, useState } from 'react';
import { Eye, EyeOff } from 'lucide-react';
import { Card, Input, Button, Alert, FormField } from '@cameleer/design-system';
import cameleerLogo from '@cameleer/design-system/assets/cameleer-logo.svg';
+import { QRCodeSVG } from 'qrcode.react';
import { startAuthentication, startRegistration as startWebAuthnReg } from '@simplewebauthn/browser';
import {
signIn, startRegistration, completeRegistration,
@@ -311,7 +312,7 @@ export function SignInPage() {
};
// --- MFA enrollment ---
- const [totpSetup, setTotpSetup] = useState<{ secret: string; secretQrCode: string; verificationId: string } | null>(null);
+ const [totpSetup, setTotpSetup] = useState<{ secret: string; otpauthUri: string; verificationId: string } | null>(null);
const [totpCode, setTotpCode] = useState('');
async function handleEnrollPasskey() {
@@ -343,7 +344,10 @@ export function SignInPage() {
setLoading(true);
try {
const data = await createTotpSecret();
- setTotpSetup(data);
+ const account = identifier || 'user';
+ const issuer = 'Cameleer';
+ const otpauthUri = `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(account)}?secret=${data.secret}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`;
+ setTotpSetup({ secret: data.secret, otpauthUri, verificationId: data.verificationId });
setTotpCode('');
setMode('mfaEnrollTotp');
} catch (err) {
@@ -358,7 +362,7 @@ export function SignInPage() {
setError(null);
setLoading(true);
try {
- const verifiedId = await verifyTotpSetup(totpCode);
+ const verifiedId = await verifyTotpSetup(totpCode, totpSetup!.verificationId);
await bindMfaProfile('Totp', verifiedId);
const bc = await generateBackupCodes();
await bindMfaProfile('BackupCode', bc.verificationId);
@@ -848,7 +852,7 @@ export function SignInPage() {
-

+
{
- const res = await request('POST', '/verification/totp/verify', { code });
+export async function verifyTotpSetup(code: string, verificationId: string): Promise {
+ const res = await request('POST', '/verification/totp/verify', { code, verificationId });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `TOTP verification failed (${res.status})`);