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