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

This commit is contained in:
BambuMan
2026-06-19 12:35:39 +02:00
committed by GitHub
parent ceb0616c82
commit 5a92115546
17 changed files with 217 additions and 2 deletions
+10
View File
@@ -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
View File
@@ -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",
@@ -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);
});
});
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)}`
);
}