diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 7484d2d1a..62b42750b 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -28,6 +28,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
@@ -6396,6 +6397,15 @@
"node": ">=6"
}
},
+ "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/frontend/package.json b/frontend/package.json
index 0dd21c5c0..90e9f0a39 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -35,6 +35,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"lucide-react": "^0.555.0",
+ "qrcode.react": "^4.2.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-i18next": "^16.3.5",
diff --git a/frontend/src/__tests__/utils/apiKeyQr.test.ts b/frontend/src/__tests__/utils/apiKeyQr.test.ts
new file mode 100644
index 000000000..f94f86ab4
--- /dev/null
+++ b/frontend/src/__tests__/utils/apiKeyQr.test.ts
@@ -0,0 +1,41 @@
+/**
+ * Tests for the API-key QR payload builder.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { buildApiKeyQrPayload, API_KEY_QR_VERSION } from '../../utils/apiKeyQr';
+
+describe('buildApiKeyQrPayload', () => {
+ it('uses the bambuddy://config scheme with v first', () => {
+ const payload = buildApiKeyQrPayload('https://printer.local', 'bb_abc123');
+ expect(payload.startsWith(`bambuddy://config?v=${API_KEY_QR_VERSION}`)).toBe(true);
+ });
+
+ it('encodes the url and key parameters', () => {
+ const payload = buildApiKeyQrPayload('https://printer.local', 'bb_abc123');
+ expect(payload).toBe('bambuddy://config?v=1&url=https%3A%2F%2Fprinter.local&key=bb_abc123');
+ });
+
+ it('URL-encodes special characters in both values', () => {
+ const baseUrl = 'http://host:5173/sub path';
+ const key = 'bb_a+b/c=d&e';
+ const payload = buildApiKeyQrPayload(baseUrl, key);
+
+ expect(payload).toContain(`url=${encodeURIComponent(baseUrl)}`);
+ expect(payload).toContain(`key=${encodeURIComponent(key)}`);
+ // The raw, unencoded key must never leak into the payload.
+ expect(payload).not.toContain(key);
+ });
+
+ it('round-trips the values back out of the query string', () => {
+ const baseUrl = 'https://my.bambuddy.example:8443';
+ const key = 'bb_ZZ/99+aa==';
+ const payload = buildApiKeyQrPayload(baseUrl, key);
+
+ const query = payload.slice(payload.indexOf('?') + 1);
+ const params = new URLSearchParams(query);
+ expect(params.get('v')).toBe(String(API_KEY_QR_VERSION));
+ expect(params.get('url')).toBe(baseUrl);
+ expect(params.get('key')).toBe(key);
+ });
+});
diff --git a/frontend/src/components/ApiKeyQRCodeModal.tsx b/frontend/src/components/ApiKeyQRCodeModal.tsx
new file mode 100644
index 000000000..4b2c7038e
--- /dev/null
+++ b/frontend/src/components/ApiKeyQRCodeModal.tsx
@@ -0,0 +1,70 @@
+import { useEffect } from 'react';
+import { QRCodeSVG } from 'qrcode.react';
+import { X, AlertTriangle } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { Button } from './Button';
+import { buildApiKeyQrPayload } from '../utils/apiKeyQr';
+
+interface ApiKeyQRCodeModalProps {
+ /** Raw API key string (only available in-memory right after creation). */
+ apiKey: string;
+ /** Base URL a client uses to reach Bambuddy. Defaults to the current origin. */
+ baseUrl?: string;
+ onClose: () => void;
+}
+
+export function ApiKeyQRCodeModal({ apiKey, baseUrl, onClose }: ApiKeyQRCodeModalProps) {
+ const { t } = useTranslation();
+ const origin = baseUrl ?? window.location.origin;
+ const payload = buildApiKeyQrPayload(origin, apiKey);
+
+ // Close on Escape key
+ useEffect(() => {
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Escape') onClose();
+ };
+ window.addEventListener('keydown', handleKeyDown);
+ return () => window.removeEventListener('keydown', handleKeyDown);
+ }, [onClose]);
+
+ return (
+
+
e.stopPropagation()}
+ >
+ {/* Header */}
+
+
{t('settings.apiKeyQrTitle')}
+
+
+
+ {/* Content */}
+
+
+ {t('settings.apiKeyQrCaption')}
+
+
+
+
+
+
+ {t('settings.apiKeyQrWarning')}
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts
index 19d9fb170..803076196 100644
--- a/frontend/src/i18n/locales/de.ts
+++ b/frontend/src/i18n/locales/de.ts
@@ -1843,6 +1843,10 @@ export default {
apiKeyCreated: 'API-Schlüssel erfolgreich erstellt',
apiKeyCopyWarning: 'Kopieren Sie diesen Schlüssel jetzt - er wird nicht mehr angezeigt!',
useInApiBrowser: 'Im API-Browser verwenden',
+ apiKeyQrButton: 'QR-Code',
+ apiKeyQrTitle: 'Zum Einrichten scannen',
+ apiKeyQrCaption: 'Mit deiner mobilen App scannen, um diesen Server und API-Schlüssel hinzuzufügen.',
+ apiKeyQrWarning: 'Enthält deinen geheimen API-Schlüssel – nicht teilen oder dort abfotografieren, wo andere ihn sehen können.',
createNewApiKey: 'Neuen API-Schlüssel erstellen',
keyName: 'Schlüsselname',
keyNamePlaceholder: 'z.B. Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts
index 48a288434..c54ef8f4c 100644
--- a/frontend/src/i18n/locales/en.ts
+++ b/frontend/src/i18n/locales/en.ts
@@ -1853,6 +1853,10 @@ export default {
apiKeyCreated: 'API Key Created Successfully',
apiKeyCopyWarning: "Copy this key now - it won't be shown again!",
useInApiBrowser: 'Use in API Browser',
+ apiKeyQrButton: 'QR code',
+ apiKeyQrTitle: 'Scan to configure',
+ apiKeyQrCaption: 'Scan with your mobile app to add this server and API key.',
+ apiKeyQrWarning: "Contains your secret API key — don't share or screenshot it where others can see.",
createNewApiKey: 'Create New API Key',
keyName: 'Key Name',
keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts
index 4d01ade21..e100f3284 100644
--- a/frontend/src/i18n/locales/es.ts
+++ b/frontend/src/i18n/locales/es.ts
@@ -1846,6 +1846,10 @@ export default {
apiKeyCreated: 'Clave API creada correctamente',
apiKeyCopyWarning: '¡Copie esta clave ahora; no se volverá a mostrar!',
useInApiBrowser: 'Usar en el explorador de API',
+ apiKeyQrButton: 'Código QR',
+ apiKeyQrTitle: 'Escanea para configurar',
+ apiKeyQrCaption: 'Escanea con tu app móvil para añadir este servidor y la clave API.',
+ apiKeyQrWarning: 'Contiene tu clave API secreta: no la compartas ni hagas capturas donde otros puedan verla.',
createNewApiKey: 'Crear nueva clave API',
keyName: 'Nombre de la clave',
keyNamePlaceholder: 'p. ej., Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts
index 9dd4fc682..cf4cdf071 100644
--- a/frontend/src/i18n/locales/fr.ts
+++ b/frontend/src/i18n/locales/fr.ts
@@ -1799,6 +1799,10 @@ export default {
apiKeyCreated: 'Clé API créée avec succès',
apiKeyCopyWarning: 'Copiez cette clé maintenant - elle ne sera plus affichée !',
useInApiBrowser: 'Utiliser dans l\'explorateur API',
+ apiKeyQrButton: 'Code QR',
+ apiKeyQrTitle: 'Scanner pour configurer',
+ apiKeyQrCaption: 'Scannez avec votre application mobile pour ajouter ce serveur et cette clé API.',
+ apiKeyQrWarning: 'Contient votre clé API secrète — ne la partagez pas et ne la capturez pas là où d\'autres peuvent la voir.',
createNewApiKey: 'Nouvelle clé API',
keyName: 'Nom de la clé',
keyNamePlaceholder: 'ex: Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/it.ts b/frontend/src/i18n/locales/it.ts
index 89f347cf9..b3cb8fa55 100644
--- a/frontend/src/i18n/locales/it.ts
+++ b/frontend/src/i18n/locales/it.ts
@@ -1799,6 +1799,10 @@ export default {
apiKeyCreated: 'Chiave API creata con successo',
apiKeyCopyWarning: 'Copia questa chiave ora - non verra mostrata di nuovo!',
useInApiBrowser: 'Usa nel Browser API',
+ apiKeyQrButton: 'Codice QR',
+ apiKeyQrTitle: 'Scansiona per configurare',
+ apiKeyQrCaption: 'Scansiona con la tua app mobile per aggiungere questo server e la chiave API.',
+ apiKeyQrWarning: 'Contiene la tua chiave API segreta: non condividerla né farne screenshot dove altri possono vederla.',
createNewApiKey: 'Crea nuova chiave API',
keyName: 'Nome chiave',
keyNamePlaceholder: 'es., Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts
index d7204cbbe..0290ef5f9 100644
--- a/frontend/src/i18n/locales/ja.ts
+++ b/frontend/src/i18n/locales/ja.ts
@@ -1842,6 +1842,10 @@ export default {
apiKeyCreated: 'APIキーを作成しました',
apiKeyCopyWarning: '今すぐこのキーをコピーしてください - 再表示されません!',
useInApiBrowser: 'APIブラウザーで使用',
+ apiKeyQrButton: 'QRコード',
+ apiKeyQrTitle: 'スキャンして設定',
+ apiKeyQrCaption: 'モバイルアプリでスキャンして、このサーバーとAPIキーを追加します。',
+ apiKeyQrWarning: '秘密のAPIキーが含まれています。他人に見られる場所で共有・スクリーンショットしないでください。',
createNewApiKey: '新しいAPIキーを作成',
keyName: 'キー名',
keyNamePlaceholder: '例: Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts
index 9f35011f8..bd49c9fde 100644
--- a/frontend/src/i18n/locales/ko.ts
+++ b/frontend/src/i18n/locales/ko.ts
@@ -1729,6 +1729,10 @@ export default {
apiKeyCreated: 'API 키가 성공적으로 생성되었습니다',
apiKeyCopyWarning: '지금 이 키를 복사하세요 - 다시 표시되지 않습니다!',
useInApiBrowser: 'API 브라우저에서 사용',
+ apiKeyQrButton: 'QR 코드',
+ apiKeyQrTitle: '스캔하여 설정',
+ apiKeyQrCaption: '모바일 앱으로 스캔하여 이 서버와 API 키를 추가하세요.',
+ apiKeyQrWarning: '비밀 API 키가 포함되어 있습니다. 다른 사람이 볼 수 있는 곳에서 공유하거나 스크린샷하지 마세요.',
createNewApiKey: '새 API 키 만들기',
keyName: '키 이름',
keyNamePlaceholder: '예: Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/pt-BR.ts b/frontend/src/i18n/locales/pt-BR.ts
index 6cc111142..318353d5d 100644
--- a/frontend/src/i18n/locales/pt-BR.ts
+++ b/frontend/src/i18n/locales/pt-BR.ts
@@ -1799,6 +1799,10 @@ export default {
apiKeyCreated: 'Chave API criada com sucesso',
apiKeyCopyWarning: 'Copie esta chave agora - ela não será exibida novamente!',
useInApiBrowser: 'Usar no Navegador API',
+ apiKeyQrButton: 'Código QR',
+ apiKeyQrTitle: 'Escaneie para configurar',
+ apiKeyQrCaption: 'Escaneie com seu app móvel para adicionar este servidor e a chave de API.',
+ apiKeyQrWarning: 'Contém sua chave de API secreta — não compartilhe nem faça captura de tela onde outros possam ver.',
createNewApiKey: 'Criar Nova Chave API',
keyName: 'Nome da Chave',
keyNamePlaceholder: 'e.g., Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/tr.ts b/frontend/src/i18n/locales/tr.ts
index 396482097..0b615cfc2 100644
--- a/frontend/src/i18n/locales/tr.ts
+++ b/frontend/src/i18n/locales/tr.ts
@@ -1846,6 +1846,10 @@ export default {
apiKeyCreated: 'API Anahtarı Başarıyla Oluşturuldu',
apiKeyCopyWarning: 'Bu anahtarı şimdi kopyalayın - bir daha gösterilmeyecek!',
useInApiBrowser: 'API Tarayıcısında Kullan',
+ apiKeyQrButton: 'QR kodu',
+ apiKeyQrTitle: 'Yapılandırmak için tarayın',
+ apiKeyQrCaption: 'Bu sunucuyu ve API anahtarını eklemek için mobil uygulamanızla tarayın.',
+ apiKeyQrWarning: 'Gizli API anahtarınızı içerir — başkalarının görebileceği yerlerde paylaşmayın veya ekran görüntüsü almayın.',
createNewApiKey: 'Yeni API Anahtarı Oluştur',
keyName: 'Anahtar Adı',
keyNamePlaceholder: 'örn., Home Assistant, OctoPrint',
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index 81b8a3752..2c9bf3e2c 100644
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -1844,6 +1844,10 @@ export default {
apiKeyCreated: 'API 密钥创建成功',
apiKeyCopyWarning: '请立即复制此密钥 - 它不会再次显示!',
useInApiBrowser: '在 API 浏览器中使用',
+ apiKeyQrButton: '二维码',
+ apiKeyQrTitle: '扫码配置',
+ apiKeyQrCaption: '使用手机应用扫描以添加此服务器和 API 密钥。',
+ apiKeyQrWarning: '包含您的机密 API 密钥——请勿在他人可见的地方分享或截图。',
createNewApiKey: '创建新 API 密钥',
keyName: '密钥名称',
keyNamePlaceholder: '例如:Home Assistant、OctoPrint',
diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts
index 8bc151daa..6a75d6304 100644
--- a/frontend/src/i18n/locales/zh-TW.ts
+++ b/frontend/src/i18n/locales/zh-TW.ts
@@ -1844,6 +1844,10 @@ export default {
apiKeyCreated: 'API 金鑰建立成功',
apiKeyCopyWarning: '請立即複製此金鑰 - 它不會再次顯示!',
useInApiBrowser: '在 API 瀏覽器中使用',
+ apiKeyQrButton: '二維碼',
+ apiKeyQrTitle: '掃碼設定',
+ apiKeyQrCaption: '使用手機應用程式掃描以新增此伺服器和 API 金鑰。',
+ apiKeyQrWarning: '包含您的機密 API 金鑰——請勿在他人可見的地方分享或截圖。',
createNewApiKey: '建立新 API 金鑰',
keyName: '金鑰名稱',
keyNamePlaceholder: '例如:Home Assistant、OctoPrint',
diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx
index 94ab3a896..b1186e67e 100644
--- a/frontend/src/pages/SettingsPage.tsx
+++ b/frontend/src/pages/SettingsPage.tsx
@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
-import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog } from 'lucide-react';
+import { Loader2, Plus, Plug, AlertTriangle, RotateCcw, Bell, Download, RefreshCw, ExternalLink, Globe, Droplets, Thermometer, FileText, Edit2, Send, CheckCircle, XCircle, History, Trash2, Zap, TrendingUp, Calendar, DollarSign, Power, PowerOff, Key, Copy, Database, X, Shield, Printer, Cylinder, Wifi, Home, Video, Users, Lock, Unlock, ChevronDown, Save, Mail, Flame, Layers, ListOrdered, Code, Search, Scale, Settings as SettingsIcon, ScanEye, Cog, QrCode } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { api } from '../api/client';
@@ -21,6 +21,7 @@ import { AddNotificationModal } from '../components/AddNotificationModal';
import { NotificationTemplateEditor } from '../components/NotificationTemplateEditor';
import { NotificationLogViewer } from '../components/NotificationLogViewer';
import { ConfirmModal } from '../components/ConfirmModal';
+import { ApiKeyQRCodeModal } from '../components/ApiKeyQRCodeModal';
import { CreateUserAdvancedAuthModal } from '../components/CreateUserAdvancedAuthModal';
import { LdapUserPicker } from '../components/LdapUserPicker';
import { SpoolmanSettings } from '../components/SpoolmanSettings';
@@ -210,6 +211,7 @@ export function SettingsPage() {
can_update_energy_cost: false,
});
const [createdAPIKey, setCreatedAPIKey] = useState(null);
+ const [showApiKeyQR, setShowApiKeyQR] = useState(false);
const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState(null);
const [testApiKey, setTestApiKey] = useState('');
@@ -3657,7 +3659,18 @@ export function SettingsPage() {
+
@@ -3668,6 +3681,16 @@ export function SettingsPage() {
)}
+ {/* QR code with base URL + key for mobile clients. Prefer the
+ configured External URL; fall back to the current origin. */}
+ {showApiKeyQR && createdAPIKey && (
+ setShowApiKeyQR(false)}
+ />
+ )}
+
{/* Create Key Form */}
{showCreateAPIKey && (
diff --git a/frontend/src/utils/apiKeyQr.ts b/frontend/src/utils/apiKeyQr.ts
new file mode 100644
index 000000000..8a71ebf1f
--- /dev/null
+++ b/frontend/src/utils/apiKeyQr.ts
@@ -0,0 +1,26 @@
+/**
+ * Helpers for the API-key QR code.
+ *
+ * The QR encodes the Bambuddy base URL and the freshly-created API key together
+ * so a mobile client can scan one code to configure both.
+ *
+ * Payload contract (fixed — bump `v` if it changes):
+ * bambuddy://config?v=1&url=&key=
+ */
+
+/** Current payload schema version. */
+export const API_KEY_QR_VERSION = 1;
+
+/**
+ * Build the QR payload string encoding the base URL + API key.
+ *
+ * @param baseUrl Origin a client uses to reach Bambuddy (origin only, no path).
+ * @param apiKey Raw API key string.
+ */
+export function buildApiKeyQrPayload(baseUrl: string, apiKey: string): string {
+ return (
+ `bambuddy://config?v=${API_KEY_QR_VERSION}` +
+ `&url=${encodeURIComponent(baseUrl)}` +
+ `&key=${encodeURIComponent(apiKey)}`
+ );
+}