瀏覽代碼

feat(api-keys): QR code on key creation encoding server URL + key (#1677) (#1701)

BambuMan 2 月之前
父節點
當前提交
5a92115546

+ 10 - 0
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",

+ 1 - 0
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",

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

+ 70 - 0
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 (
+    <div
+      className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"
+      onClick={onClose}
+    >
+      <div
+        className="bg-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary w-full max-w-sm"
+        onClick={(e) => e.stopPropagation()}
+      >
+        {/* Header */}
+        <div className="flex items-center justify-between px-6 py-4 border-b border-bambu-dark-tertiary">
+          <h2 className="text-lg font-semibold text-white">{t('settings.apiKeyQrTitle')}</h2>
+          <button
+            onClick={onClose}
+            className="text-bambu-gray hover:text-white transition-colors"
+            aria-label={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </div>
+
+        {/* Content */}
+        <div className="p-6 flex flex-col items-center">
+          <p className="text-sm text-bambu-gray mb-4 text-center">
+            {t('settings.apiKeyQrCaption')}
+          </p>
+          <div className="bg-white p-4 rounded-lg mb-4">
+            <QRCodeSVG value={payload} size={256} />
+          </div>
+          <div className="flex items-start gap-2 text-xs text-amber-400 mb-4">
+            <AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
+            <span>{t('settings.apiKeyQrWarning')}</span>
+          </div>
+          <Button onClick={onClose} className="w-full">
+            {t('common.close')}
+          </Button>
+        </div>
+      </div>
+    </div>
+  );
+}

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 4 - 0
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',

+ 25 - 2
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<string | null>(null);
+  const [showApiKeyQR, setShowApiKeyQR] = useState(false);
   const [showDeleteAPIKeyConfirm, setShowDeleteAPIKeyConfirm] = useState<number | null>(null);
   const [testApiKey, setTestApiKey] = useState('');
 
@@ -3657,7 +3659,18 @@ export function SettingsPage() {
                         <Button
                           variant="secondary"
                           size="sm"
-                          onClick={() => setCreatedAPIKey(null)}
+                          onClick={() => setShowApiKeyQR(true)}
+                        >
+                          <QrCode className="w-4 h-4" />
+                          {t('settings.apiKeyQrButton')}
+                        </Button>
+                        <Button
+                          variant="secondary"
+                          size="sm"
+                          onClick={() => {
+                            setShowApiKeyQR(false);
+                            setCreatedAPIKey(null);
+                          }}
                         >
                           {t('common.dismiss')}
                         </Button>
@@ -3668,6 +3681,16 @@ export function SettingsPage() {
               </Card>
             )}
 
+            {/* QR code with base URL + key for mobile clients. Prefer the
+                configured External URL; fall back to the current origin. */}
+            {showApiKeyQR && createdAPIKey && (
+              <ApiKeyQRCodeModal
+                apiKey={createdAPIKey}
+                baseUrl={localSettings?.external_url || undefined}
+                onClose={() => setShowApiKeyQR(false)}
+              />
+            )}
+
             {/* Create Key Form */}
             {showCreateAPIKey && (
               <Card className="mb-6">

+ 26 - 0
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=<encodeURIComponent(baseUrl)>&key=<encodeURIComponent(apiKey)>
+ */
+
+/** 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)}`
+  );
+}