Browse Source

Create streaming overlay tokens and import existing URLs (#3207)

Adam Spice 14 hours ago
parent
commit
3596eb0c31
100 changed files with 611 additions and 94 deletions
  1. 2 0
      backend/app/api/routes/auth.py
  2. 41 0
      backend/tests/integration/test_long_lived_tokens_api.py
  3. 1 1
      frontend/src/__tests__/components/Layout.test.tsx
  4. 1 1
      frontend/src/__tests__/components/SpoolFormModal.test.tsx
  5. 280 7
      frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx
  6. 5 2
      frontend/src/components/OverlayBrandingControls.tsx
  7. 122 23
      frontend/src/components/StreamOverlayBuilder.tsx
  8. 9 2
      frontend/src/i18n/locales/de.ts
  9. 9 2
      frontend/src/i18n/locales/en.ts
  10. 9 2
      frontend/src/i18n/locales/es.ts
  11. 9 2
      frontend/src/i18n/locales/fr.ts
  12. 9 2
      frontend/src/i18n/locales/it.ts
  13. 9 2
      frontend/src/i18n/locales/ja.ts
  14. 9 2
      frontend/src/i18n/locales/ko.ts
  15. 9 2
      frontend/src/i18n/locales/nl.ts
  16. 9 2
      frontend/src/i18n/locales/pt-BR.ts
  17. 9 2
      frontend/src/i18n/locales/ru.ts
  18. 9 2
      frontend/src/i18n/locales/sv.ts
  19. 9 2
      frontend/src/i18n/locales/tr.ts
  20. 9 2
      frontend/src/i18n/locales/uk.ts
  21. 9 2
      frontend/src/i18n/locales/zh-CN.ts
  22. 9 2
      frontend/src/i18n/locales/zh-TW.ts
  23. 18 13
      frontend/src/pages/CameraTokensPage.tsx
  24. 6 5
      frontend/src/pages/SettingsPage.tsx
  25. 0 0
      static/assets/ImagePreviewModal-B_nL3_8y.js
  26. 0 1
      static/assets/PdfPreviewModal-DFgmChOn.js
  27. 0 1
      static/assets/SpreadsheetPreviewModal-BZBKSNz6.js
  28. 0 1
      static/assets/chunk-aKtaBQYM.js
  29. 0 1
      static/assets/index-BljpHSYg.js
  30. 0 1
      static/assets/index-CaVfzyDI.css
  31. BIN
      static/assets/occt-import-js-BhHfLpto.wasm
  32. 0 0
      static/assets/papaparse.min-CR43Mv99.js
  33. 0 0
      static/assets/pdf-KdLife5c.js
  34. 0 0
      static/assets/pdf.worker.min-0idpi3xP.js
  35. 0 1
      static/assets/pdf.worker.min-DpOxPwGP.js
  36. BIN
      static/assets/pdfjs/cmaps/78-EUC-H.bcmap
  37. BIN
      static/assets/pdfjs/cmaps/78-EUC-V.bcmap
  38. BIN
      static/assets/pdfjs/cmaps/78-H.bcmap
  39. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap
  40. BIN
      static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap
  41. BIN
      static/assets/pdfjs/cmaps/78-V.bcmap
  42. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap
  43. BIN
      static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap
  44. BIN
      static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap
  45. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap
  46. BIN
      static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap
  47. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap
  48. BIN
      static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap
  49. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap
  50. BIN
      static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap
  51. BIN
      static/assets/pdfjs/cmaps/Add-H.bcmap
  52. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap
  53. BIN
      static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap
  54. BIN
      static/assets/pdfjs/cmaps/Add-V.bcmap
  55. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap
  56. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap
  57. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap
  58. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap
  59. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap
  60. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap
  61. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap
  62. BIN
      static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap
  63. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap
  64. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap
  65. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap
  66. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap
  67. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap
  68. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap
  69. BIN
      static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap
  70. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap
  71. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap
  72. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap
  73. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap
  74. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap
  75. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap
  76. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap
  77. BIN
      static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap
  78. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap
  79. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap
  80. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap
  81. BIN
      static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap
  82. BIN
      static/assets/pdfjs/cmaps/B5-H.bcmap
  83. BIN
      static/assets/pdfjs/cmaps/B5-V.bcmap
  84. BIN
      static/assets/pdfjs/cmaps/B5pc-H.bcmap
  85. BIN
      static/assets/pdfjs/cmaps/B5pc-V.bcmap
  86. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap
  87. BIN
      static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap
  88. BIN
      static/assets/pdfjs/cmaps/CNS1-H.bcmap
  89. BIN
      static/assets/pdfjs/cmaps/CNS1-V.bcmap
  90. BIN
      static/assets/pdfjs/cmaps/CNS2-H.bcmap
  91. 0 3
      static/assets/pdfjs/cmaps/CNS2-V.bcmap
  92. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap
  93. BIN
      static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap
  94. BIN
      static/assets/pdfjs/cmaps/ETen-B5-H.bcmap
  95. BIN
      static/assets/pdfjs/cmaps/ETen-B5-V.bcmap
  96. 0 3
      static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap
  97. BIN
      static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap
  98. BIN
      static/assets/pdfjs/cmaps/EUC-H.bcmap
  99. BIN
      static/assets/pdfjs/cmaps/EUC-V.bcmap
  100. BIN
      static/assets/pdfjs/cmaps/Ext-H.bcmap

+ 2 - 0
backend/app/api/routes/auth.py

@@ -1728,6 +1728,7 @@ def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> di
 @router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
 async def create_long_lived_camera_token(
     payload: dict,
+    response: Response,
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
     db: AsyncSession = Depends(get_db),
 ):
@@ -1786,6 +1787,7 @@ async def create_long_lived_camera_token(
         scope,
         created.record.expires_at.isoformat(),
     )
+    response.headers["Cache-Control"] = "no-store"
     return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
 
 

+ 41 - 0
backend/tests/integration/test_long_lived_tokens_api.py

@@ -319,3 +319,44 @@ class TestCameraStreamTokenVerification:
         await _setup_admin(async_client, suffix="_verify_garbage")
         assert await verify_camera_stream_token("bblt_aaaaaaaa_garbage") is False
         assert await verify_camera_stream_token("not-a-real-token") is False
+
+
+class TestHashOnlyOverlayTokens:
+    async def test_creation_is_hash_only_without_encryption(self, async_client, db_session, monkeypatch):
+        from backend.app.core import encryption
+        from backend.app.core.auth import verify_password
+        from backend.app.models.long_lived_token import LongLivedToken
+        from backend.app.services.long_lived_tokens import verify_token
+
+        jwt = await _setup_admin(async_client, suffix="_hash_only")
+        monkeypatch.setattr(encryption, "_get_fernet", lambda: None)
+        headers = {"Authorization": f"Bearer {jwt}"}
+        created = await async_client.post(
+            "/api/v1/auth/tokens", headers=headers, json={"name": "OBS", "scope": "overlay", "expires_in_days": 30}
+        )
+        assert created.status_code == 201
+        assert created.headers["cache-control"] == "no-store"
+        body = created.json()
+        row = await db_session.get(LongLivedToken, body["id"])
+        assert verify_password(body["token"], row.secret_hash)
+        assert await verify_token(db_session, body["token"], scope="overlay") is not None
+        listing = (await async_client.get("/api/v1/auth/tokens", headers=headers)).json()
+        assert listing[0]["token"] is None
+        assert "can_reuse" not in listing[0]
+        assert body["token"] not in str(listing)
+
+    async def test_recovery_endpoint_is_absent(self, async_client):
+        from backend.app.api.routes.auth import router
+
+        assert not any("overlay-secret" in route.path for route in router.routes)
+        jwt = await _setup_admin(async_client, suffix="_no_recovery")
+        headers = {"Authorization": f"Bearer {jwt}"}
+        created = await async_client.post(
+            "/api/v1/auth/tokens", headers=headers, json={"name": "OBS", "scope": "overlay", "expires_in_days": 30}
+        )
+        response = await async_client.post(
+            f"/api/v1/auth/tokens/{created.json()['id']}/overlay-secret", headers=headers
+        )
+        # The SPA static fallback may answer 405 for an unknown POST.
+        assert response.status_code in (404, 405)
+        assert created.json()["token"] not in response.text

+ 1 - 1
frontend/src/__tests__/components/Layout.test.tsx

@@ -626,7 +626,7 @@ describe('Layout', () => {
         expect(sidebar?.querySelector('a[href="/files"]')).toBeInTheDocument();
       });
 
-      expect(findMakerWorldNavLink()).toBeNull();
+      await waitFor(() => expect(findMakerWorldNavLink()).toBeNull());
     });
 
     it('shows the MakerWorld nav entry when the user has makerworld:view', async () => {

+ 1 - 1
frontend/src/__tests__/components/SpoolFormModal.test.tsx

@@ -1065,7 +1065,7 @@ describe('SpoolFormModal — assignment actions (#1336, #2978)', () => {
 
     expect(screen.queryByRole('button', { name: /unassign/i })).not.toBeInTheDocument();
     const assignBtn = screen.getByRole('button', { name: /assign spool/i });
-    expect(assignBtn).not.toBeDisabled();
+    await waitFor(() => expect(assignBtn).not.toBeDisabled());
 
     fireEvent.click(assignBtn);
     expect(await screen.findByRole('heading', { name: /assign to AMS/i })).toBeInTheDocument();

+ 280 - 7
frontend/src/__tests__/components/StreamOverlayBuilder.test.tsx

@@ -6,12 +6,13 @@
  * camera stream until it is asked to.
  */
 
-import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
 import { screen, waitFor, fireEvent, act, within } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import { http, HttpResponse } from 'msw';
 import { render } from '../utils';
 import { server } from '../mocks/server';
+import { setAuthToken } from '../../api/client';
 import { StreamOverlayBuilder } from '../../components/StreamOverlayBuilder';
 
 const printers = [
@@ -19,6 +20,17 @@ const printers = [
   { id: 2, name: 'P1S', ip_address: '192.168.1.101', serial_number: '01P00A000000002', model: 'P1S' },
 ];
 
+const createdToken = {
+  id: 42, user_id: 1, name: 'OBS', scope: 'overlay', lookup_prefix: '12345678',
+  created_at: '2026-01-01T00:00:00Z', expires_at: '2099-01-01T00:00:00Z',
+  last_used_at: null, token: null,
+};
+
+function setupSignedIn() {
+  setAuthToken('test-login');
+  server.use(http.get('*/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false })));
+}
+
 // The URL is rendered inside a <code>, so read it back the way a user would.
 function shownUrl(): string {
   const code = document.querySelector('code');
@@ -26,6 +38,7 @@ function shownUrl(): string {
 }
 
 describe('StreamOverlayBuilder', () => {
+  afterEach(() => { setAuthToken(null); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
   beforeEach(() => {
     server.use(http.get('/api/v1/printers', () => HttpResponse.json(printers)), http.get('/api/v1/settings/overlay-logo', () => new HttpResponse(null, { status: 404 })));
   });
@@ -59,6 +72,7 @@ describe('StreamOverlayBuilder', () => {
       expect(url.searchParams.get('backgroundTransparency')).toBe(artwork === '2' ? '65' : null);
       expect(url.searchParams.get('layout')).toBe(orientation === 'Portrait' ? 'portrait' : null);
       expect(url.searchParams.get('token')).toBe('bblt_example');
+      expect(group.getByText(/token=\*\*\*\*/)).not.toHaveTextContent('bblt_example');
       expect(url.searchParams.get('size')).toBe('large');
       expect(url.searchParams.get('camera')).toBe('false');
       expect(url.searchParams.get('show')).toContain('nozzle');
@@ -92,6 +106,195 @@ describe('StreamOverlayBuilder', () => {
     vi.unstubAllGlobals();
   });
 
+  it('imports portrait sources and resets to landscape when layout is absent', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    const input = screen.getByLabelText('Existing overlay URL');
+    await user.type(input, 'https://obs.local/overlay/2?layout=portrait&token=bblt_portrait');
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.getByLabelText('Layout')).toHaveValue('portrait');
+    expect(new URL(shownUrl()).searchParams.get('layout')).toBe('portrait');
+    await user.click(screen.getByRole('button', { name: 'Show preview' }));
+    expect(new URL(screen.getByTitle('Portrait preview').getAttribute('src')!).searchParams.get('token')).toBe('bblt_portrait');
+    await user.type(input, 'https://obs.local/overlay/1?token=bblt_landscape');
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.getByLabelText('Layout')).toHaveValue('landscape');
+    expect(new URL(shownUrl()).searchParams.has('layout')).toBe(false);
+    expect(screen.queryByTitle('Portrait preview')).not.toBeInTheDocument();
+  });
+
+  it('accepts an existing raw token without requiring saved-token recovery', async () => {
+    const user = userEvent.setup();
+    const view = render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    const manual = screen.getByLabelText('Manual token');
+    expect(manual).toHaveAttribute('type', 'password');
+    await user.type(manual, 'bblt_existing');
+    expect(shownUrl()).not.toContain('bblt_existing');
+    expect(shownUrl()).toContain('token=****');
+    await user.click(screen.getByRole('button', { name: 'Show preview' }));
+    expect(new URL(screen.getByTitle('Landscape preview').getAttribute('src')!).searchParams.get('token')).toBe('bblt_existing');
+    await user.click(screen.getByRole('button', { name: 'Show token' }));
+    expect(manual).toHaveAttribute('type', 'text');
+    expect(shownUrl()).toContain('token=bblt_existing');
+    view.unmount();
+    render(<StreamOverlayBuilder />);
+    expect(screen.getByLabelText('Manual token')).toHaveValue('');
+    expect(shownUrl()).not.toContain('bblt_existing');
+  });
+
+  it('imports locally, masks credentials, restores settings and keeps the current origin', async () => {
+    const user = userEvent.setup();
+    const storageWrites = vi.spyOn(Storage.prototype, 'setItem');
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    const input = screen.getByLabelText('Existing overlay URL');
+    expect(input).toHaveAttribute('type', 'password');
+    // Chrome ignores autocomplete="off" on password fields and could fill the
+    // login password in; new-password keeps both secret fields unfilled.
+    expect(input).toHaveAttribute('autocomplete', 'new-password');
+    expect(screen.getByLabelText('Manual token')).toHaveAttribute('autocomplete', 'new-password');
+    await user.type(input, 'https://other.example/overlay/2?token=bblt_imported&show=nozzle,status&size=large&fps=5&artwork=2&camera=false');
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(input).toHaveValue('');
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_imported');
+    expect(shownUrl()).not.toContain('bblt_imported');
+    expect(new URL(shownUrl()).origin).toBe(window.location.origin);
+    expect(screen.getByLabelText('Printer')).toHaveValue('2');
+    expect(screen.getByLabelText('Text size')).toHaveValue('large');
+    expect(screen.getByLabelText('Artwork')).toHaveValue('2');
+    expect(screen.getByLabelText('Camera feed')).not.toBeChecked();
+    expect(new URL(shownUrl()).searchParams.get('show')).toBe('status,nozzle');
+    expect(new URL(shownUrl()).searchParams.get('fps')).toBe('5');
+    expect(document.querySelector('iframe')).toBeNull();
+    expect(storageWrites).not.toHaveBeenCalledWith(expect.anything(), expect.stringContaining('bblt_imported'));
+    await user.type(input, 'http://old.example/overlay/1');
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.getByLabelText('Manual token')).toHaveValue('');
+    expect(screen.getByLabelText('Text size')).toHaveValue('medium');
+    expect(screen.getByLabelText('Artwork')).toHaveValue('1');
+    expect(screen.getByLabelText('Camera feed')).toBeChecked();
+    expect(new URL(shownUrl()).searchParams.has('fps')).toBe(false);
+  });
+
+  it.each([
+    'javascript:alert(1)', 'https://example.com/overlay/nope',
+    'https://example.com/overlay/0', 'https://example.com/overlay/9007199254740992',
+    'https://user:password@example.com/overlay/2',
+    'https://example.com/overlay/2?token=a&token=b',
+  ])('rejects invalid or unsupported imports without changing settings: %s', async (value) => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    await user.type(screen.getByLabelText('Manual token'), 'bblt_keep');
+    const original = shownUrl();
+    await user.type(screen.getByLabelText('Existing overlay URL'), value);
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.getByRole('alert')).toBeInTheDocument();
+    expect(shownUrl()).toBe(original);
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_keep');
+  });
+
+  it.each([
+    ['999', '30'], ['0', '1'], ['-5', '1'], ['5.9', '5'], ['20fps', '20'], ['bad', null], ['', null],
+  ])('imports fps=%s with runtime clamping and ignores unknown configuration', async (fps, expected) => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    fireEvent.change(screen.getByLabelText('Existing overlay URL'), { target: { value:
+      `https://other.example/overlay/2?fps=${fps}&show=status,unknown&size=huge&unrelated=ignored` } });
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    const url = new URL(shownUrl());
+    expect(url.searchParams.get('fps')).toBe(expected);
+    expect(url.searchParams.get('show')).toBe('status');
+    expect(screen.getByLabelText('Text size')).toHaveValue('medium');
+    expect(url.searchParams.has('unrelated')).toBe(false);
+  });
+
+  it('ignores a fragment, as the overlay page does', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    fireEvent.change(screen.getByLabelText('Existing overlay URL'), { target: { value:
+      'https://other.example/overlay/2?token=bblt_fragment&size=large#scene' } });
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_fragment');
+    expect(screen.getByLabelText('Text size')).toHaveValue('large');
+    expect(new URL(shownUrl()).hash).toBe('');
+  });
+
+  it('imports branding and transparency and resets missing values on the next import', async () => {
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    fireEvent.change(screen.getByLabelText('Existing overlay URL'), { target: { value:
+      'https://other.example/overlay/2?artwork=2&backgroundTransparency=150&logo=1&progressFrom=%23aabbcc&progressTo=%23112233' } });
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    const params = new URL(shownUrl()).searchParams;
+    expect(params.get('backgroundTransparency')).toBe('100');
+    expect(params.get('logo')).toBe('1');
+    expect(params.get('progressFrom')).toBe('#aabbcc');
+    expect(params.get('progressTo')).toBe('#112233');
+    expect(screen.getByLabelText('From colour (hex)')).toHaveValue('#aabbcc');
+    expect(screen.getByLabelText('To colour (hex)')).toHaveValue('#112233');
+    fireEvent.change(screen.getByLabelText('Existing overlay URL'), { target: { value:
+      'https://other.example/overlay/2?artwork=2&progressFrom=invalid&progressTo=%23112233' } });
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(shownUrl()).not.toMatch(/backgroundTransparency|logo=|progressFrom|progressTo/);
+    expect(screen.getByLabelText('From colour (hex)')).toHaveValue('#00ae42');
+  });
+
+  it('locks import during a logo upload so its completion cannot overwrite imported branding', async () => {
+    let finish = () => {};
+    const gate = new Promise<void>((resolve) => { finish = resolve; });
+    let started = false;
+    server.use(http.post('/api/v1/settings/overlay-logo', async () => {
+      started = true;
+      await gate;
+      return HttpResponse.json({ status: 'ok' });
+    }));
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    fireEvent.change(screen.getByLabelText('Existing overlay URL'), { target: { value:
+      'https://other.example/overlay/2?progressFrom=%23aabbcc&progressTo=%23112233' } });
+    expect(screen.getByRole('button', { name: 'Import URL' })).toBeEnabled();
+    await user.upload(screen.getByLabelText('Upload logo'), new File(['png'], 'logo.png', { type: 'image/png' }));
+    await waitFor(() => expect(started).toBe(true));
+    expect(screen.getByLabelText('Existing overlay URL')).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Import URL' })).toBeDisabled();
+    await act(async () => { finish(); await gate; });
+    await waitFor(() => expect(screen.getByRole('button', { name: 'Import URL' })).toBeEnabled());
+    await user.click(screen.getByRole('button', { name: 'Import URL' }));
+    expect(new URL(shownUrl()).searchParams.has('logo')).toBe(false);
+    expect(new URL(shownUrl()).searchParams.get('progressFrom')).toBe('#aabbcc');
+    expect(screen.getByLabelText('From colour (hex)')).toHaveValue('#aabbcc');
+  });
+
+  it('shows a server creation error, unlocks controls, and allows successful repeated creation', async () => {
+    setupSignedIn();
+    server.use(http.post('*/api/v1/auth/tokens', () => HttpResponse.json({ detail: 'Token limit reached' }, { status: 400 })));
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await user.click(await screen.findByRole('button', { name: 'Create overlay token' }));
+    await user.type(screen.getByLabelText('Token name'), 'OBS');
+    await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
+    expect(await screen.findByText('Token limit reached')).toBeInTheDocument();
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeEnabled();
+    expect(screen.getByLabelText('Manual token')).toBeEnabled();
+    expect(screen.getByLabelText('Existing overlay URL')).toBeEnabled();
+    server.use(http.post('*/api/v1/auth/tokens', () => HttpResponse.json({ ...createdToken, token: 'bblt_retry' }, { status: 201 })));
+    await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
+    await waitFor(() => expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_retry'));
+    await user.click(screen.getByRole('button', { name: 'Create overlay token' }));
+    await user.type(screen.getByLabelText('Token name'), 'OBS again');
+    server.use(http.post('*/api/v1/auth/tokens', () => HttpResponse.json({ ...createdToken, token: 'bblt_second' }, { status: 201 })));
+    await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
+    await waitFor(() => expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_second'));
+  });
+
   it('starts on the first printer with the overlay defaults', async () => {
     render(<StreamOverlayBuilder />);
 
@@ -236,17 +439,87 @@ describe('StreamOverlayBuilder', () => {
     await waitFor(() => expect(shownUrl()).not.toContain('size='));
   });
 
-  it('appends a token and warns that the URL is now a key', async () => {
+  it('uses a newly created overlay token directly without retrieval', async () => {
+    setupSignedIn();
+    server.use(
+      http.post('*/api/v1/auth/tokens', async ({ request }) => {
+        expect(await request.json()).toEqual({ name: 'OBS', scope: 'overlay', expires_in_days: 90 });
+        return HttpResponse.json({ ...createdToken, token: 'bblt_saved' }, { status: 201 });
+      }),
+    );
     const user = userEvent.setup();
     render(<StreamOverlayBuilder />);
+    await user.click(await screen.findByRole('button', { name: 'Create overlay token' }));
+    await user.selectOptions(screen.getByLabelText('Printer'), '2');
+    await user.type(screen.getByLabelText('Token name'), 'OBS');
+    expect(screen.getByLabelText('Scope')).toBeDisabled();
+    await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
+    await screen.findByText(/This URL contains a token/);
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_saved');
+    await user.click(screen.getByRole('button', { name: 'Show token' }));
+    expect(shownUrl()).toContain('/overlay/2?');
+    expect(shownUrl()).toContain('token=bblt_saved');
+  });
 
-    await waitFor(() => expect(screen.getByLabelText(/token/i)).toBeInTheDocument());
-    expect(screen.queryByText(/This URL contains a token/)).not.toBeInTheDocument();
+  it('locks conflicting controls until a delayed token is available', async () => {
+    setupSignedIn();
+    let finish = () => {};
+    const gate = new Promise<void>((resolve) => { finish = resolve; });
+    let started = false;
+    server.use(http.post('*/api/v1/auth/tokens', async () => {
+      started = true;
+      await gate;
+      return HttpResponse.json({ ...createdToken, token: 'bblt_late' }, { status: 201 });
+    }));
+    const user = userEvent.setup();
+    render(<StreamOverlayBuilder />);
+    await user.click(await screen.findByRole('button', { name: 'Create overlay token' }));
+    await user.type(screen.getByLabelText('Existing overlay URL'), 'https://other.example/overlay/2?token=bblt_import');
+    expect(screen.getByRole('button', { name: 'Import URL' })).toBeEnabled();
+    await user.type(screen.getByLabelText('Token name'), 'OBS');
+    await user.click(screen.getByRole('button', { name: 'Create', exact: true }));
+    await waitFor(() => expect(started).toBe(true));
+    expect(screen.getByRole('button', { name: 'Cancel' })).toBeDisabled();
+    expect(screen.getByLabelText('Manual token')).toBeDisabled();
+    expect(screen.getByLabelText('Existing overlay URL')).toBeDisabled();
+    expect(screen.getByRole('button', { name: 'Import URL' })).toBeDisabled();
+    await act(async () => { finish(); await gate; });
+    await screen.findByText('Token created');
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_late');
+    expect(screen.getByLabelText('Manual token')).toBeEnabled();
+    expect(screen.getByLabelText('Existing overlay URL')).toBeEnabled();
+    await user.click(screen.getByRole('button', { name: 'Show token' }));
+    expect(shownUrl()).toContain('token=bblt_late');
+    await user.click(screen.getByRole('button', { name: 'Create overlay token' }));
+    await user.click(screen.getByRole('button', { name: 'Cancel' }));
+    expect(screen.getByLabelText('Manual token')).toHaveValue('bblt_late');
+  });
 
-    await user.type(screen.getByLabelText(/token/i), 'bblt_abc');
+  it('does not offer token creation without a signed-in user', async () => {
+    render(<StreamOverlayBuilder />);
+    await screen.findByRole('option', { name: 'P1S' });
+    expect(screen.queryByRole('button', { name: 'Create overlay token' })).not.toBeInTheDocument();
+  });
 
-    await waitFor(() => expect(shownUrl()).toContain('token=bblt_abc'));
-    expect(screen.getByText(/This URL contains a token/)).toBeInTheDocument();
+  it.each([true, false])('copies the full masked URL with secure context = %s', async (secure) => {
+    setupSignedIn();
+    const user = userEvent.setup();
+    vi.stubGlobal('isSecureContext', secure);
+    let copied = '';
+    if (secure) {
+      vi.spyOn(navigator.clipboard, 'writeText').mockImplementation(async (value) => { copied = value; });
+    } else {
+      Object.defineProperty(document, 'execCommand', { configurable: true, value: () => {
+        copied = document.querySelector('textarea')?.value ?? '';
+        return true;
+      } });
+    }
+    render(<StreamOverlayBuilder />);
+    await user.type(screen.getByLabelText('Manual token'), 'bblt_saved');
+    await screen.findByText(/This URL contains a token/);
+    await user.click(screen.getByRole('button', { name: 'Copy', exact: true }));
+    expect(new URL(copied).searchParams.get('token')).toBe('bblt_saved');
+    expect(shownUrl()).not.toContain('bblt_saved');
   });
 
   it('opens no camera stream until the preview is asked for', async () => {

+ 5 - 2
frontend/src/components/OverlayBrandingControls.tsx

@@ -6,20 +6,22 @@ import { useToast } from '../contexts/ToastContext';
 import { useOverlayLogo } from '../hooks/useOverlayLogo';
 import { isOverlayColour, type OverlayBranding } from '../utils/overlayBranding';
 
-export function OverlayBrandingControls({ value, onChange }: {
+export function OverlayBrandingControls({ value, onChange, onBusyChange }: {
   value: OverlayBranding;
   onChange: (value: OverlayBranding) => void;
+  onBusyChange?: (busy: boolean) => void;
 }) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const { authEnabled, hasPermission } = useAuth();
   const canEdit = !authEnabled || hasPermission('settings:update');
   const [busy, setBusy] = useState(false);
-  const [draft, setDraft] = useState({ from: '#00ae42', to: '#00ae42' });
+  const [draft, setDraft] = useState({ from: value.from || '#00ae42', to: value.to || '#00ae42' });
   const logo = useOverlayLogo(true, null, value.logoRevision);
 
   const saveLogo = async (file: File | null) => {
     setBusy(true);
+    onBusyChange?.(true);
     try {
       if (file) await api.uploadOverlayLogo(file);
       else await api.deleteOverlayLogo();
@@ -28,6 +30,7 @@ export function OverlayBrandingControls({ value, onChange }: {
       showToast(error instanceof Error ? error.message : t('streamOverlay.branding.failed'), 'error');
     } finally {
       setBusy(false);
+      onBusyChange?.(false);
     }
   };
   const setColour = (key: 'from' | 'to', colour: string) => {

+ 122 - 23
frontend/src/components/StreamOverlayBuilder.tsx

@@ -4,9 +4,9 @@
  * The overlay at /overlay/{printerId} has been configurable by query string
  * since #2613, but only for people who found the parameters in the wiki. The
  * issue asked for the field set to be selectable "through the web UI"; this is
- * that surface. It composes a URL, it does not persist anything — the URL *is*
- * the configuration, which keeps a scene in OBS reproducible by copy-paste and
- * means two displays can show different fields off one token.
+ * that surface. Appearance is configured in the URL. Tokens entered, imported, or created
+ * here remain in component memory; existing credentials cannot be recovered.
+ * The URL is the configuration so an OBS browser source needs no saved server-side profile.
  */
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
@@ -14,7 +14,9 @@ import { Copy, ExternalLink, Eye, EyeOff } from 'lucide-react';
 import { api, type Printer } from '../api/client';
 import { useToast } from '../contexts/ToastContext';
 import { OverlayBrandingControls } from './OverlayBrandingControls';
-import { DEFAULT_BRANDING } from '../utils/overlayBranding';
+import { DEFAULT_BRANDING, overlayGradient } from '../utils/overlayBranding';
+import { useAuth } from '../contexts/AuthContext';
+import { CreateTokenForm } from '../pages/CameraTokensPage';
 import { NumberInput } from './NumberInput';
 import { OverlayFrame } from './OverlayFrame';
 import { OVERLAY_DIMENSIONS, type OverlayLayout } from '../utils/overlayLayout';
@@ -42,9 +44,17 @@ const DEFAULT_FIELDS = ['progress', 'layers', 'eta', 'filename', 'status'];
 
 const DEFAULT_FPS = 15;
 
-export function StreamOverlayBuilder() {
+export function StreamOverlayBuilder({ onTokenCreated }: { onTokenCreated?: () => void }) {
   const { t } = useTranslation();
   const { showToast } = useToast();
+  const { user, hasPermission } = useAuth();
+  const [creatingToken, setCreatingToken] = useState(false);
+  const [submittingToken, setSubmittingToken] = useState(false);
+  const [revealToken, setRevealToken] = useState(false);
+  const [manualToken, setManualToken] = useState('');
+  const token = manualToken;
+  const [importUrl, setImportUrl] = useState('');
+  const [importError, setImportError] = useState(false);
 
   const [printers, setPrinters] = useState<Printer[]>([]);
   const [printerId, setPrinterId] = useState<number | null>(null);
@@ -56,9 +66,10 @@ export function StreamOverlayBuilder() {
   const [artwork, setArtwork] = useState<'1' | '2'>('1');
   const [backgroundTransparency, setBackgroundTransparency] = useState(0);
   const [showCamera, setShowCamera] = useState(true);
-  const [token, setToken] = useState('');
   const [branding, setBranding] = useState(DEFAULT_BRANDING);
   const [layout, setLayout] = useState<OverlayLayout | 'both'>('landscape');
+  const [brandingImportRevision, setBrandingImportRevision] = useState(0);
+  const [brandingBusy, setBrandingBusy] = useState(false);
   const [preview, setPreview] = useState(false);
 
   useEffect(() => {
@@ -68,7 +79,7 @@ export function StreamOverlayBuilder() {
         const list = await api.getPrinters();
         if (cancelled) return;
         setPrinters(list);
-        if (list.length > 0) setPrinterId(list[0].id);
+        if (list.length > 0) setPrinterId((current) => current ?? list[0].id);
       } catch {
         // A failed printer list only costs the picker its options — the builder
         // still works if the user types a printer number into the URL by hand,
@@ -109,6 +120,58 @@ export function StreamOverlayBuilder() {
     });
   }, [printerId, fields, size, fps, showCamera, token, artwork, layout, branding, backgroundTransparency]);
 
+  const displayedUrl = (url: string) => {
+    const masked = new URL(url);
+    if (masked.searchParams.has('token') && !revealToken) masked.searchParams.set('token', '****');
+    return masked.toString();
+  };
+
+  const importExistingUrl = () => {
+    try {
+      const parsed = new URL(importUrl.trim());
+      const match = /^\/overlay\/([1-9]\d*)\/?$/.exec(parsed.pathname);
+      if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || !match) throw new Error();
+      const id = Number(match[1]);
+      if (!Number.isSafeInteger(id)) throw new Error();
+      const params = parsed.searchParams;
+      if (params.getAll('token').length > 1) throw new Error();
+      const importedFields = params.has('show') ? params.get('show')!.split(',').filter(
+        (field) => FIELDS.some((supportedField) => supportedField.key === field),
+      ) : DEFAULT_FIELDS;
+      const sizeParam = params.get('size');
+      const importedSize = sizeParam === 'small' || sizeParam === 'large' ? sizeParam : 'medium';
+      const fpsParam = parseInt(params.get('fps') || '15', 10);
+      const importedFps = Math.min(Math.max(Number.isNaN(fpsParam) ? DEFAULT_FPS : fpsParam, 1), 30);
+      const transparencyParam = Number(params.get('backgroundTransparency'));
+      const importedTransparency = Number.isFinite(transparencyParam)
+        ? Math.min(100, Math.max(0, transparencyParam)) : 0;
+      const from = params.get('progressFrom');
+      const to = params.get('progressTo');
+      const validGradient = overlayGradient(from, to);
+      // Validate everything before updating any state. Never open the imported origin.
+      setPrinterId(id);
+      setLayout(params.get('layout') === 'portrait' ? 'portrait' : 'landscape');
+      setFields(importedFields);
+      setSize(importedSize as OverlaySize);
+      setFps(importedFps);
+      setArtwork(params.get('artwork') === '2' ? '2' : '1');
+      setShowCamera(!['false', '0'].includes(params.get('camera') ?? ''));
+      setBackgroundTransparency(importedTransparency);
+      setBranding((current) => ({
+        ...current, logo: params.get('logo') === '1',
+        from: validGradient ? from! : '', to: validGradient ? to! : '',
+      }));
+      setBrandingImportRevision((revision) => revision + 1);
+      setManualToken(params.get('token') ?? '');
+      setPreview(false);
+      setRevealToken(false);
+      setImportUrl('');
+      setImportError(false);
+    } catch {
+      setImportError(true);
+    }
+  };
+
   const toggleField = (key: string) => {
     setFields((prev) => (prev.includes(key) ? prev.filter((f) => f !== key) : [...prev, key]));
   };
@@ -127,7 +190,7 @@ export function StreamOverlayBuilder() {
         document.body.appendChild(ta);
         try {
           ta.select();
-          document.execCommand('copy');
+          if (!document.execCommand('copy')) throw new Error();
         } finally {
           document.body.removeChild(ta);
         }
@@ -139,7 +202,7 @@ export function StreamOverlayBuilder() {
   };
 
   return (
-    <div>
+    <div className="@container/overlay min-w-0">
       <p className="text-sm text-bambu-gray mb-4">
         {t(
           'streamOverlay.builder.description',
@@ -147,7 +210,32 @@ export function StreamOverlayBuilder() {
         )}
       </p>
 
-      <div className="grid gap-4 md:grid-cols-2">
+      <div className="mb-4 space-y-2">
+        <label htmlFor="overlay-builder-import" className="block text-sm font-medium text-white">{t('streamOverlay.builder.importUrl')}</label>
+        <input id="overlay-builder-import" type="password" autoComplete="new-password" spellCheck={false} value={importUrl} disabled={submittingToken || brandingBusy}
+          onChange={(event) => { setImportUrl(event.target.value); setImportError(false); }}
+          className="w-full min-w-0 px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary" />
+        <button type="button" disabled={submittingToken || brandingBusy || !importUrl.trim()} onClick={importExistingUrl} className="px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md disabled:opacity-50">{t('streamOverlay.builder.importAction')}</button>
+        <p className="text-xs text-bambu-gray">{t('streamOverlay.builder.importHint')}</p>
+        {importError && <p role="alert" className="text-sm text-red-400">{t('streamOverlay.builder.importError')}</p>}
+      </div>
+
+      <div className="mb-4 space-y-2">
+        {user && hasPermission('camera:view') && (
+          <button type="button" disabled={submittingToken} onClick={() => setCreatingToken((current) => !current)} className="px-3 py-2 bg-bambu-dark-tertiary text-white rounded-md">
+            {t(creatingToken ? 'common.cancel' : 'streamOverlay.builder.createToken')}
+          </button>
+        )}
+        {creatingToken && user && hasPermission('camera:view') && <CreateTokenForm fixedScope="overlay" onSubmittingChange={setSubmittingToken} onCreated={(created) => {
+          onTokenCreated?.();
+          if (!created.token) return;
+          setPreview(false);
+          setManualToken(created.token);
+          setRevealToken(false);
+          setCreatingToken(false);
+        }} />}
+      </div>
+      <div className="grid grid-cols-1 gap-4 @min-[28rem]/overlay:grid-cols-2">
         <div>
           <label
             htmlFor="overlay-builder-printer"
@@ -162,6 +250,7 @@ export function StreamOverlayBuilder() {
             className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
           >
             {printers.length === 0 && <option value="">{t('common.loading', 'Loading…')}</option>}
+            {printerId !== null && !printers.some((printer) => printer.id === printerId) && <option value={printerId}>{printerId}</option>}
             {printers.map((p) => (
               <option key={p.id} value={p.id}>
                 {p.name}
@@ -260,21 +349,31 @@ export function StreamOverlayBuilder() {
         </div>
 
         <div>
-          <label htmlFor="overlay-builder-token" className="block text-sm font-medium text-white mb-1">
-            {t('streamOverlay.builder.token', 'Streaming Overlay token (optional)')}
+          <label htmlFor="overlay-builder-manual-token" className="block text-sm font-medium text-white mt-3 mb-1">
+            {t('streamOverlay.builder.manualToken')}
           </label>
           <input
-            id="overlay-builder-token"
-            type="text"
-            value={token}
-            onChange={(e) => setToken(e.target.value)}
+            id="overlay-builder-manual-token"
+            type={revealToken ? 'text' : 'password'}
+            value={manualToken}
+            disabled={submittingToken}
+            autoComplete="new-password"
+            spellCheck={false}
+            onChange={(event) => {
+              setPreview(false);
+              setRevealToken(false);
+              setManualToken(event.target.value);
+            }}
             placeholder="bblt_…"
-            className="w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none font-mono text-xs"
+            className="w-full min-w-0 px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary"
           />
+          <button type="button" disabled={!token} onClick={() => setRevealToken((current) => !current)} className="mt-2 text-sm text-bambu-gray disabled:opacity-50">
+            {t(revealToken ? 'streamOverlay.builder.hideToken' : 'streamOverlay.builder.showToken')}
+          </button>
           <p className="text-xs text-bambu-gray mt-1">
             {t(
-              'streamOverlay.builder.tokenHint',
-              'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
+              'streamOverlay.builder.credentialHint',
+              'Create a token above, enter an existing token, or import an overlay URL. Copy the URL before leaving: tokens are held only in memory and cannot be recovered from their stored hash.',
             )}
           </p>
         </div>
@@ -284,7 +383,7 @@ export function StreamOverlayBuilder() {
         <legend className="text-sm font-medium text-white mb-2">
           {t('streamOverlay.builder.fields', 'Fields to show')}
         </legend>
-        <div className="grid gap-2 sm:grid-cols-2 md:grid-cols-3">
+        <div className="grid grid-cols-1 gap-2 @min-[24rem]/overlay:grid-cols-2 @min-[36rem]/overlay:grid-cols-3">
           {FIELDS.map((field) => (
             <label key={field.key} className="flex items-center gap-2 text-sm text-bambu-gray">
               <input
@@ -314,7 +413,7 @@ export function StreamOverlayBuilder() {
         </p>
       </fieldset>
 
-      <OverlayBrandingControls value={branding} onChange={setBranding} />
+      <OverlayBrandingControls key={brandingImportRevision} value={branding} onChange={setBranding} onBusyChange={setBrandingBusy} />
 
       <div className="mt-4">
         {outputs.map(({ layout: orientation, url }) => (
@@ -326,8 +425,8 @@ export function StreamOverlayBuilder() {
               {t('streamOverlay.builder.sourceDimensions', OVERLAY_DIMENSIONS[orientation])}
             </p>
             <div className="flex flex-wrap items-center gap-2">
-              <code className="w-full px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
-                {url}
+              <code className="w-full px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono">
+                {displayedUrl(url)}
               </code>
               <button type="button" onClick={() => void copyUrl(url)}
                 className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90">

+ 9 - 2
frontend/src/i18n/locales/de.ts

@@ -3690,6 +3690,15 @@ export default {
       artwork: 'Gestaltung',
       artworkClassic: 'Klassisch',
       artworkV2: 'Version 2',
+      createToken: 'Overlay-Token erstellen',
+      showToken: 'Token anzeigen',
+      hideToken: 'Token ausblenden',
+      credentialHint: 'Erstelle oben ein Token, gib ein vorhandenes ein oder importiere eine Overlay-URL. Kopiere die URL vor dem Verlassen: Tokens bleiben nur im Arbeitsspeicher und lassen sich aus ihrem gespeicherten Hash nicht wiederherstellen.',
+      importUrl: 'Vorhandene Overlay-URL',
+      importAction: 'URL importieren',
+      importHint: 'Importiere eine URL lokal, um ihr Token und ihre Einstellungen auf diesem Server zu verwenden. Einstellungen, die Bambuddy nicht kennt, werden ignoriert.',
+      importError: 'Gib eine gültige HTTP(S)-Overlay-URL mit positiver Druckernummer, höchstens einem Token und ohne Benutzername oder Passwort ein.',
+      manualToken: 'Token manuell eingeben',
       title: 'Stream-Overlay',
       description: 'Erstellt die URL für ein Stream-Overlay — eine bildschirmfüllende Kameraansicht mit eingeblendeten Live-Druckdaten, für OBS, ein Wanddisplay oder jede andere Browserquelle. Felder auswählen und URL kopieren.',
       printer: 'Drucker',
@@ -3699,8 +3708,6 @@ export default {
       sizeLarge: 'Groß',
       fps: 'Bildrate',
       fpsHint: 'A1- und P1-Kameras liefern höchstens etwa 5 Bilder pro Sekunde, unabhängig vom eingestellten Wert.',
-      token: 'Stream-Overlay-Token (optional)',
-      tokenHint: 'Nur nötig, wenn die Anmeldung aktiviert ist: OBS hat keine eigene Sitzung. Oben eines mit dem Bereich Stream-Overlay erstellen.',
       tokenWarning: 'Diese URL enthält ein Token — wer sie lesen kann, sieht den Stream und den Dateinamen. Token widerrufen, um den Zugriff zu beenden.',
       fields: 'Anzuzeigende Felder',
       fieldPrinter: 'Druckername',

+ 9 - 2
frontend/src/i18n/locales/en.ts

@@ -3722,6 +3722,15 @@ export default {
       artwork: 'Artwork',
       artworkClassic: 'Classic',
       artworkV2: 'Version 2',
+      createToken: 'Create overlay token',
+      showToken: 'Show token',
+      hideToken: 'Hide token',
+      credentialHint: 'Create a token above, enter an existing token, or import an overlay URL. Copy the URL before leaving: tokens are held only in memory and cannot be recovered from their stored hash.',
+      importUrl: 'Existing overlay URL',
+      importAction: 'Import URL',
+      importHint: 'Import a URL locally to reuse its token and settings on this server. Settings Bambuddy doesn\'t recognise are ignored.',
+      importError: 'Enter a valid HTTP(S) overlay URL with a positive printer number, at most one token and no user name or password.',
+      manualToken: 'Manual token',
       title: 'Streaming Overlay',
       description: 'Build the URL for a streaming overlay — a full-screen camera view with live print data drawn over it, for OBS, a wall display, or any browser source. Pick the fields you want and copy the URL.',
       printer: 'Printer',
@@ -3731,8 +3740,6 @@ export default {
       sizeLarge: 'Large',
       fps: 'Frame rate',
       fpsHint: 'A1 and P1 cameras top out around 5 fps whatever you ask for.',
-      token: 'Streaming Overlay token (optional)',
-      tokenHint: 'Only needed when login is enabled: OBS has no session of its own. Create one above with the Streaming Overlay scope.',
       tokenWarning: 'This URL contains a token — anyone who can read it can watch the stream and see the file name. Revoke the token to cut it off.',
       fields: 'Fields to show',
       fieldPrinter: 'Printer name',

+ 9 - 2
frontend/src/i18n/locales/es.ts

@@ -3692,6 +3692,15 @@ export default {
       artwork: 'Diseño',
       artworkClassic: 'Clásico',
       artworkV2: 'Versión 2',
+      createToken: 'Crear token de superposición',
+      showToken: 'Mostrar token',
+      hideToken: 'Ocultar token',
+      credentialHint: 'Crea un token arriba, introduce uno existente o importa una URL de superposición. Copia la URL antes de salir: los tokens solo se mantienen en memoria y no se pueden recuperar de su hash almacenado.',
+      importUrl: 'URL de superposición existente',
+      importAction: 'Importar URL',
+      importHint: 'Importa una URL localmente para reutilizar su token y ajustes en este servidor. Los ajustes que Bambuddy no reconoce se ignoran.',
+      importError: 'Introduce una URL de superposición HTTP(S) válida con un número de impresora positivo, como máximo un token y sin usuario ni contraseña.',
+      manualToken: 'Token manual',
       title: 'Superposición de emisión',
       description: 'Crea la URL de una superposición de emisión: una vista de cámara a pantalla completa con los datos de impresión en directo encima, para OBS, una pantalla de pared o cualquier fuente de navegador. Elige los campos y copia la URL.',
       printer: 'Impresora',
@@ -3701,8 +3710,6 @@ export default {
       sizeLarge: 'Grande',
       fps: 'Fotogramas por segundo',
       fpsHint: 'Las cámaras A1 y P1 no pasan de unos 5 fps, sea cual sea el valor solicitado.',
-      token: 'Token de superposición (opcional)',
-      tokenHint: 'Solo hace falta si el inicio de sesión está activado: OBS no tiene sesión propia. Crea uno arriba con el ámbito Superposición de emisión.',
       tokenWarning: 'Esta URL contiene un token: cualquiera que pueda leerla verá la emisión y el nombre del archivo. Revoca el token para cortar el acceso.',
       fields: 'Campos que mostrar',
       fieldPrinter: 'Nombre de la impresora',

+ 9 - 2
frontend/src/i18n/locales/fr.ts

@@ -3678,6 +3678,15 @@ export default {
       artwork: 'Habillage',
       artworkClassic: 'Classique',
       artworkV2: 'Version 2',
+      createToken: 'Créer un jeton de superposition',
+      showToken: 'Afficher le jeton',
+      hideToken: 'Masquer le jeton',
+      credentialHint: 'Créez un jeton ci-dessus, saisissez un jeton existant ou importez une URL de superposition. Copiez l’URL avant de quitter : les jetons restent uniquement en mémoire et ne peuvent pas être récupérés depuis leur empreinte enregistrée.',
+      importUrl: 'URL de superposition existante',
+      importAction: 'Importer l’URL',
+      importHint: 'Importez une URL localement pour réutiliser son jeton et ses réglages sur ce serveur. Les réglages que Bambuddy ne reconnaît pas sont ignorés.',
+      importError: 'Saisissez une URL de superposition HTTP(S) valide avec un numéro d’imprimante positif, au plus un jeton et sans nom d’utilisateur ni mot de passe.',
+      manualToken: 'Jeton manuel',
       title: 'Incrustation de diffusion',
       description: 'Compose l\'URL d\'une incrustation de diffusion — une vue caméra plein écran avec les données d\'impression en direct par-dessus, pour OBS, un écran mural ou toute source navigateur. Choisissez les champs voulus et copiez l\'URL.',
       printer: 'Imprimante',
@@ -3687,8 +3696,6 @@ export default {
       sizeLarge: 'Grande',
       fps: 'Fréquence d\'images',
       fpsHint: 'Les caméras A1 et P1 plafonnent autour de 5 images par seconde, quelle que soit la valeur demandée.',
-      token: 'Jeton d\'incrustation (facultatif)',
-      tokenHint: 'Nécessaire uniquement si la connexion est activée : OBS n\'a pas de session. Créez-en un ci-dessus avec la portée Incrustation de diffusion.',
       tokenWarning: 'Cette URL contient un jeton — quiconque peut la lire peut voir le flux et le nom du fichier. Révoquez le jeton pour couper l\'accès.',
       fields: 'Champs à afficher',
       fieldPrinter: 'Nom de l\'imprimante',

+ 9 - 2
frontend/src/i18n/locales/it.ts

@@ -3677,6 +3677,15 @@ export default {
       artwork: 'Grafica',
       artworkClassic: 'Classica',
       artworkV2: 'Versione 2',
+      createToken: 'Crea token overlay',
+      showToken: 'Mostra token',
+      hideToken: 'Nascondi token',
+      credentialHint: 'Crea un token sopra, inseriscine uno esistente o importa un URL di sovrapposizione. Copia l’URL prima di uscire: i token restano solo in memoria e non possono essere recuperati dal loro hash salvato.',
+      importUrl: 'URL di sovrapposizione esistente',
+      importAction: 'Importa URL',
+      importHint: 'Importa un URL localmente per riutilizzare il token e le impostazioni su questo server. Le impostazioni che Bambuddy non riconosce vengono ignorate.',
+      importError: 'Inserisci un URL overlay HTTP(S) valido con un numero di stampante positivo, al massimo un token e senza nome utente o password.',
+      manualToken: 'Token manuale',
       title: 'Overlay per streaming',
       description: 'Compone l\'URL di un overlay per streaming: una vista telecamera a schermo intero con i dati di stampa in tempo reale sovrapposti, per OBS, un display a parete o qualsiasi sorgente browser. Scegli i campi e copia l\'URL.',
       printer: 'Stampante',
@@ -3686,8 +3695,6 @@ export default {
       sizeLarge: 'Grande',
       fps: 'Frequenza fotogrammi',
       fpsHint: 'Le telecamere A1 e P1 si fermano intorno a 5 fps, qualunque valore venga richiesto.',
-      token: 'Token overlay (facoltativo)',
-      tokenHint: 'Serve solo con il login attivo: OBS non ha una sessione propria. Creane uno sopra con ambito Overlay per streaming.',
       tokenWarning: 'Questo URL contiene un token: chi riesce a leggerlo può vedere lo streaming e il nome del file. Revoca il token per interrompere l\'accesso.',
       fields: 'Campi da mostrare',
       fieldPrinter: 'Nome stampante',

+ 9 - 2
frontend/src/i18n/locales/ja.ts

@@ -3690,6 +3690,15 @@ export default {
       artwork: 'デザイン',
       artworkClassic: 'クラシック',
       artworkV2: 'バージョン 2',
+      createToken: 'オーバーレイトークンを作成',
+      showToken: 'トークンを表示',
+      hideToken: 'トークンを隠す',
+      credentialHint: '上でトークンを作成するか、既存のトークンを入力するか、オーバーレイURLをインポートしてください。ページを離れる前にURLをコピーしてください。トークンはメモリ内にのみ保持され、保存されたハッシュからは復元できません。',
+      importUrl: '既存のオーバーレイURL',
+      importAction: 'URLをインポート',
+      importHint: 'URLをローカルでインポートし、トークンと設定をこのサーバーで再利用します。Bambuddyが認識しない設定は無視されます。',
+      importError: '正のプリンター番号を含み、トークンが1つまでで、ユーザー名やパスワードを含まない有効なHTTP(S)オーバーレイURLを入力してください。',
+      manualToken: '手動入力トークン',
       title: 'ストリームオーバーレイ',
       description: 'ストリームオーバーレイのURLを作成します。全画面のカメラ映像に印刷中の情報を重ねて表示するもので、OBSや壁掛けディスプレイなどのブラウザソースで使えます。表示する項目を選んでURLをコピーしてください。',
       printer: 'プリンター',
@@ -3699,8 +3708,6 @@ export default {
       sizeLarge: '大',
       fps: 'フレームレート',
       fpsHint: 'A1およびP1のカメラは、指定した値にかかわらず毎秒5フレーム程度が上限です。',
-      token: 'ストリームオーバーレイトークン(任意)',
-      tokenHint: 'ログインを有効にしている場合のみ必要です。OBS自体はセッションを持ちません。上の欄でストリームオーバーレイのスコープを選んで作成してください。',
       tokenWarning: 'このURLにはトークンが含まれます。URLを読める人は誰でも映像とファイル名を見られます。アクセスを止めるにはトークンを失効させてください。',
       fields: '表示する項目',
       fieldPrinter: 'プリンター名',

+ 9 - 2
frontend/src/i18n/locales/ko.ts

@@ -3513,6 +3513,15 @@ export default {
       artwork: '디자인',
       artworkClassic: '클래식',
       artworkV2: '버전 2',
+      createToken: '오버레이 토큰 만들기',
+      showToken: '토큰 표시',
+      hideToken: '토큰 숨기기',
+      credentialHint: '위에서 토큰을 만들거나 기존 토큰을 입력하거나 오버레이 URL을 가져오세요. 페이지를 떠나기 전에 URL을 복사하세요. 토큰은 메모리에만 유지되며 저장된 해시에서 복구할 수 없습니다.',
+      importUrl: '기존 오버레이 URL',
+      importAction: 'URL 가져오기',
+      importHint: 'URL을 로컬에서 가져와 토큰과 설정을 이 서버에서 재사용합니다. Bambuddy가 인식하지 못하는 설정은 무시됩니다.',
+      importError: '양수 프린터 번호가 있고 토큰이 최대 하나이며 사용자 이름이나 비밀번호가 없는 유효한 HTTP(S) 오버레이 URL을 입력하세요.',
+      manualToken: '토큰 직접 입력',
       title: '스트림 오버레이',
       description: '스트림 오버레이 URL을 만듭니다. 전체 화면 카메라 영상 위에 실시간 출력 정보를 겹쳐 보여주며 OBS, 벽걸이 디스플레이 등 모든 브라우저 소스에서 쓸 수 있습니다. 원하는 항목을 고르고 URL을 복사하세요.',
       printer: '프린터',
@@ -3522,8 +3531,6 @@ export default {
       sizeLarge: '크게',
       fps: '프레임 속도',
       fpsHint: 'A1 및 P1 카메라는 요청한 값과 관계없이 초당 약 5프레임이 한계입니다.',
-      token: '스트림 오버레이 토큰(선택)',
-      tokenHint: '로그인을 사용할 때만 필요합니다. OBS에는 자체 세션이 없습니다. 위에서 스트림 오버레이 범위로 발급하세요.',
       tokenWarning: '이 URL에는 토큰이 들어 있습니다. URL을 읽을 수 있는 사람은 누구나 영상과 파일 이름을 볼 수 있습니다. 접근을 끊으려면 토큰을 폐기하세요.',
       fields: '표시할 항목',
       fieldPrinter: '프린터 이름',

+ 9 - 2
frontend/src/i18n/locales/nl.ts

@@ -3720,6 +3720,15 @@ export default {
       artwork: 'Vormgeving',
       artworkClassic: 'Klassiek',
       artworkV2: 'Versie 2',
+      createToken: 'Overlay-token maken',
+      showToken: 'Token tonen',
+      hideToken: 'Token verbergen',
+      credentialHint: 'Maak hierboven een token aan, voer een bestaand token in of importeer een overlay-URL. Kopieer de URL voordat je vertrekt: tokens blijven alleen in het geheugen en kunnen niet uit hun opgeslagen hash worden hersteld.',
+      importUrl: 'Bestaande overlay-URL',
+      importAction: 'URL importeren',
+      importHint: 'Importeer een URL lokaal om het token en de instellingen op deze server te gebruiken. Instellingen die Bambuddy niet kent, worden genegeerd.',
+      importError: 'Voer een geldige HTTP(S)-overlay-URL in met een positief printernummer, hoogstens één token en zonder gebruikersnaam of wachtwoord.',
+      manualToken: 'Handmatig token',
       title: 'Streamingoverlay',
       description: 'Bouw de URL voor een streamingoverlay — een camerabeeld op volledig scherm met live afdrukgegevens eroverheen, voor OBS, een wanddisplay of elke browserbron. Kies de velden die je wilt en kopieer de URL.',
       printer: 'Printer',
@@ -3729,8 +3738,6 @@ export default {
       sizeLarge: 'Groot',
       fps: 'Framesnelheid',
       fpsHint: 'A1- en P1-camera\'s halen maximaal ongeveer 5 fps, ongeacht wat je instelt.',
-      token: 'Token voor streamingoverlay (optioneel)',
-      tokenHint: 'Alleen nodig wanneer login is ingeschakeld: OBS heeft geen eigen sessie. Maak hierboven een token met het bereik Streamingoverlay.',
       tokenWarning: 'Deze URL bevat een token — iedereen die hem kan lezen kan de stream bekijken en de bestandsnaam zien. Trek het token in om de toegang te stoppen.',
       fields: 'Velden om te tonen',
       fieldPrinter: 'Printernaam',

+ 9 - 2
frontend/src/i18n/locales/pt-BR.ts

@@ -3677,6 +3677,15 @@ export default {
       artwork: 'Visual',
       artworkClassic: 'Clássico',
       artworkV2: 'Versão 2',
+      createToken: 'Criar token de sobreposição',
+      showToken: 'Mostrar token',
+      hideToken: 'Ocultar token',
+      credentialHint: 'Crie um token acima, insira um existente ou importe uma URL de sobreposição. Copie a URL antes de sair: os tokens ficam apenas na memória e não podem ser recuperados do hash armazenado.',
+      importUrl: 'URL de sobreposição existente',
+      importAction: 'Importar URL',
+      importHint: 'Importe uma URL localmente para reutilizar o token e as configurações neste servidor. Configurações que o Bambuddy não reconhece são ignoradas.',
+      importError: 'Insira uma URL de sobreposição HTTP(S) válida com um número de impressora positivo, no máximo um token e sem usuário ou senha.',
+      manualToken: 'Token manual',
       title: 'Sobreposição de transmissão',
       description: 'Monta a URL de uma sobreposição de transmissão: a câmera em tela cheia com os dados da impressão sobrepostos, para OBS, um painel de parede ou qualquer fonte de navegador. Escolha os campos e copie a URL.',
       printer: 'Impressora',
@@ -3686,8 +3695,6 @@ export default {
       sizeLarge: 'Grande',
       fps: 'Taxa de quadros',
       fpsHint: 'Câmeras A1 e P1 chegam no máximo a cerca de 5 fps, qualquer que seja o valor pedido.',
-      token: 'Token de sobreposição (opcional)',
-      tokenHint: 'Só é necessário com login ativado: o OBS não tem sessão própria. Crie um acima com o escopo Sobreposição de transmissão.',
       tokenWarning: 'Esta URL contém um token: quem conseguir lê-la pode assistir à transmissão e ver o nome do arquivo. Revogue o token para cortar o acesso.',
       fields: 'Campos a exibir',
       fieldPrinter: 'Nome da impressora',

+ 9 - 2
frontend/src/i18n/locales/ru.ts

@@ -3505,6 +3505,15 @@ export default {
       artwork: 'Оформление',
       artworkClassic: 'Классическое',
       artworkV2: 'Версия 2',
+      createToken: 'Создать токен оверлея',
+      showToken: 'Показать токен',
+      hideToken: 'Скрыть токен',
+      credentialHint: 'Создайте токен выше, введите существующий или импортируйте URL оверлея. Скопируйте URL перед уходом: токены хранятся только в памяти и не могут быть восстановлены из сохранённого хеша.',
+      importUrl: 'Существующий URL оверлея',
+      importAction: 'Импортировать URL',
+      importHint: 'Импортируйте URL локально, чтобы использовать его токен и настройки на этом сервере. Настройки, которые Bambuddy не распознаёт, игнорируются.',
+      importError: 'Введите корректный HTTP(S) URL оверлея с положительным номером принтера, не более чем одним токеном и без имени пользователя или пароля.',
+      manualToken: 'Токен вручную',
       title: "Оформление трансляции",
       description: "Собирает адрес оформления трансляции: полноэкранное изображение камеры с наложенными данными о печати — для OBS, настенного экрана или любого источника-браузера. Выберите нужные поля и скопируйте адрес.",
       printer: "Принтер",
@@ -3514,8 +3523,6 @@ export default {
       sizeLarge: "Крупный",
       fps: "Частота кадров",
       fpsHint: "Камеры A1 и P1 выдают не более примерно 5 кадров в секунду, какое бы значение вы ни задали.",
-      token: "Токен оформления трансляции (необязательно)",
-      tokenHint: "Нужен только при включённом входе: у OBS нет собственного сеанса. Создайте его выше с областью «Оформление трансляции».",
       tokenWarning: "Этот адрес содержит токен: любой, кто его прочитает, увидит трансляцию и имя файла. Отзовите токен, чтобы закрыть доступ.",
       fields: "Показываемые поля",
       fieldPrinter: "Имя принтера",

+ 9 - 2
frontend/src/i18n/locales/sv.ts

@@ -3719,6 +3719,15 @@ errors: {
       artwork: 'Utseende',
       artworkClassic: 'Klassisk',
       artworkV2: 'Version 2',
+      createToken: 'Skapa overlay-token',
+      showToken: 'Visa token',
+      hideToken: 'Dölj token',
+      credentialHint: 'Skapa en token ovan, ange en befintlig eller importera en överläggs-URL. Kopiera URL:en innan du lämnar sidan: token finns bara i minnet och kan inte återställas från sin lagrade hash.',
+      importUrl: 'Befintlig överläggs-URL',
+      importAction: 'Importera URL',
+      importHint: 'Importera en URL lokalt för att återanvända dess token och inställningar på den här servern. Inställningar som Bambuddy inte känner igen ignoreras.',
+      importError: 'Ange en giltig HTTP(S)-overlay-URL med ett positivt skrivarnummer, högst en token och utan användarnamn eller lösenord.',
+      manualToken: 'Manuell token',
       title: 'Strömningsöverlägg',
       description: 'Bygg URL:en för ett strömningsöverlägg — en helskärmskameravy med live utskriftsdata som visas ovanpå. Välj de fält du vill ha och kopiera URL:en.',
       printer: 'Skrivare',
@@ -3728,8 +3737,6 @@ errors: {
       sizeLarge: 'Stor',
       fps: 'Bildfrekvens',
       fpsHint: 'A1- och P1-kameror når upp till cirka 5 fps oavsett vad du anger.',
-      token: 'Strömningsöverläggs-token (valfritt)',
-      tokenHint: 'Krävs endast när inloggning är aktiverad: OBS har ingen egen session. Skapa en ovan med strömningsöverläggsomfång.',
       tokenWarning: 'Denna URL innehåller en token — vem som helst som kan läsa den kan titta på strömmen och se filnamnet. Återkalla token för att blockera den.',
       fields: 'Fält att visa',
       fieldPrinter: 'Skrivarnamn',

+ 9 - 2
frontend/src/i18n/locales/tr.ts

@@ -3692,6 +3692,15 @@ export default {
       artwork: 'Tasarım',
       artworkClassic: 'Klasik',
       artworkV2: 'Sürüm 2',
+      createToken: 'Kaplama belirteci oluştur',
+      showToken: 'Belirteci göster',
+      hideToken: 'Belirteci gizle',
+      credentialHint: 'Yukarıdan bir belirteç oluşturun, mevcut bir belirteci girin veya bir katman URL’sini içe aktarın. Ayrılmadan önce URL’yi kopyalayın: belirteçler yalnızca bellekte tutulur ve saklanan karmalarından kurtarılamaz.',
+      importUrl: 'Mevcut katman URL’si',
+      importAction: 'URL’yi içe aktar',
+      importHint: 'Belirteci ve ayarları bu sunucuda yeniden kullanmak için URL’yi yerel olarak içe aktarın. Bambuddy’nin tanımadığı ayarlar yok sayılır.',
+      importError: 'Pozitif bir yazıcı numarası, en fazla bir belirteç içeren ve kullanıcı adı ya da parola içermeyen geçerli bir HTTP(S) kaplama URL’si girin.',
+      manualToken: 'Manuel belirteç',
       title: 'Yayın Kaplaması',
       description: 'Yayın kaplaması için URL oluşturur: tam ekran kamera görüntüsünün üzerine canlı baskı bilgileri bindirilir; OBS, duvar ekranı veya herhangi bir tarayıcı kaynağı için. İstediğiniz alanları seçip URL\'yi kopyalayın.',
       printer: 'Yazıcı',
@@ -3701,8 +3710,6 @@ export default {
       sizeLarge: 'Büyük',
       fps: 'Kare hızı',
       fpsHint: 'A1 ve P1 kameraları, hangi değeri isterseniz isteyin saniyede yaklaşık 5 karede kalır.',
-      token: 'Yayın Kaplaması belirteci (isteğe bağlı)',
-      tokenHint: 'Yalnızca oturum açma etkinken gerekir: OBS\'nin kendi oturumu yoktur. Yukarıdan Yayın Kaplaması kapsamıyla bir tane oluşturun.',
       tokenWarning: 'Bu URL bir belirteç içerir: okuyabilen herkes yayını izleyebilir ve dosya adını görebilir. Erişimi kesmek için belirteci iptal edin.',
       fields: 'Gösterilecek alanlar',
       fieldPrinter: 'Yazıcı adı',

+ 9 - 2
frontend/src/i18n/locales/uk.ts

@@ -3717,6 +3717,15 @@ export default {
       artwork: 'Оформлення',
       artworkClassic: 'Класичне',
       artworkV2: 'Версія 2',
+      createToken: 'Створити токен оверлею',
+      showToken: 'Показати токен',
+      hideToken: 'Приховати токен',
+      credentialHint: 'Створіть токен вище, введіть наявний або імпортуйте URL оверлею. Скопіюйте URL перед виходом: токени зберігаються лише в пам’яті й не можуть бути відновлені зі збереженого хешу.',
+      importUrl: 'Наявний URL оверлею',
+      importAction: 'Імпортувати URL',
+      importHint: 'Імпортуйте URL локально, щоб використати його токен і налаштування на цьому сервері. Налаштування, які Bambuddy не розпізнає, ігноруються.',
+      importError: 'Введіть дійсний HTTP(S) URL оверлею з додатним номером принтера, щонайбільше одним токеном і без імені користувача чи пароля.',
+      manualToken: 'Токен вручну',
       title: "Оформлення трансляції",
       description: "Складає адресу оформлення трансляції: повноекранне зображення камери з накладеними даними друку — для OBS, настінного екрана або будь-якого джерела-браузера. Виберіть потрібні поля та скопіюйте адресу.",
       printer: "Принтер",
@@ -3726,8 +3735,6 @@ export default {
       sizeLarge: "Великий",
       fps: "Частота кадрів",
       fpsHint: "Камери A1 і P1 видають щонайбільше близько 5 кадрів за секунду, хоч би яке значення ви задали.",
-      token: "Токен оформлення трансляції (необов'язково)",
-      tokenHint: "Потрібен лише за увімкненого входу: OBS не має власного сеансу. Створіть його вище з областю «Оформлення трансляції».",
       tokenWarning: "Ця адреса містить токен: будь-хто, хто її прочитає, побачить трансляцію та назву файлу. Відкличте токен, щоб закрити доступ.",
       fields: "Поля для показу",
       fieldPrinter: "Назва принтера",

+ 9 - 2
frontend/src/i18n/locales/zh-CN.ts

@@ -3677,6 +3677,15 @@ export default {
       artwork: '外观',
       artworkClassic: '经典',
       artworkV2: '第 2 版',
+      createToken: '创建叠加层令牌',
+      showToken: '显示令牌',
+      hideToken: '隐藏令牌',
+      credentialHint: '在上方创建令牌、输入现有令牌或导入叠加层 URL。离开前请复制 URL:令牌仅保留在内存中,无法从已存储的哈希值中恢复。',
+      importUrl: '现有叠加层 URL',
+      importAction: '导入 URL',
+      importHint: '在本地导入 URL,以在此服务器上重用其令牌和设置。Bambuddy 无法识别的设置将被忽略。',
+      importError: '请输入有效的 HTTP(S) 叠加层 URL,需包含正整数打印机编号、最多一个令牌,且不含用户名或密码。',
+      manualToken: '手动输入令牌',
       title: '直播叠加层',
       description: '生成直播叠加层的网址:全屏摄像头画面上叠加实时打印信息,可用于 OBS、墙面显示屏或任何浏览器源。选择需要的字段并复制网址。',
       printer: '打印机',
@@ -3686,8 +3695,6 @@ export default {
       sizeLarge: '大',
       fps: '帧率',
       fpsHint: '无论设置多少,A1 和 P1 的摄像头最高约为每秒 5 帧。',
-      token: '直播叠加层令牌(可选)',
-      tokenHint: '仅在启用登录时需要:OBS 没有自己的登录会话。请在上方以直播叠加层范围创建一个。',
       tokenWarning: '此网址包含令牌:任何能看到它的人都可以观看画面并看到文件名。撤销令牌即可切断访问。',
       fields: '要显示的字段',
       fieldPrinter: '打印机名称',

+ 9 - 2
frontend/src/i18n/locales/zh-TW.ts

@@ -3677,6 +3677,15 @@ export default {
       artwork: '外觀',
       artworkClassic: '經典',
       artworkV2: '第 2 版',
+      createToken: '建立疊加層權杖',
+      showToken: '顯示權杖',
+      hideToken: '隱藏權杖',
+      credentialHint: '在上方建立權杖、輸入現有權杖或匯入疊加層 URL。離開前請複製 URL:權杖僅保留在記憶體中,無法從已儲存的雜湊值還原。',
+      importUrl: '現有疊加層 URL',
+      importAction: '匯入 URL',
+      importHint: '在本機匯入 URL,以在此伺服器上重用其權杖和設定。Bambuddy 無法識別的設定將被忽略。',
+      importError: '請輸入有效的 HTTP(S) 疊加層 URL,需包含正整數印表機編號、最多一個權杖,且不含使用者名稱或密碼。',
+      manualToken: '手動輸入權杖',
       title: '直播疊加層',
       description: '產生直播疊加層的網址:全螢幕攝影機畫面上疊加即時列印資訊,可用於 OBS、牆面顯示器或任何瀏覽器來源。選擇需要的欄位並複製網址。',
       printer: '印表機',
@@ -3686,8 +3695,6 @@ export default {
       sizeLarge: '大',
       fps: '影格率',
       fpsHint: '無論設定多少,A1 與 P1 的攝影機最高約為每秒 5 影格。',
-      token: '直播疊加層權杖(選填)',
-      tokenHint: '僅在啟用登入時需要:OBS 沒有自己的登入工作階段。請在上方以直播疊加層範圍建立一個。',
       tokenWarning: '此網址包含權杖:任何能看到它的人都可以觀看畫面並看到檔案名稱。撤銷權杖即可中止存取。',
       fields: '要顯示的欄位',
       fieldPrinter: '印表機名稱',

+ 18 - 13
frontend/src/pages/CameraTokensPage.tsx

@@ -39,50 +39,54 @@ function isExpired(iso: string): boolean {
 
 interface CreateTokenFormProps {
   onCreated: (token: LongLivedCameraToken) => void;
+  fixedScope?: LongLivedTokenScope;
+  onSubmittingChange?: (submitting: boolean) => void;
 }
 
-function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
+export function CreateTokenForm({ onCreated, fixedScope, onSubmittingChange }: CreateTokenFormProps) {
   const { t } = useTranslation();
   const { showToast } = useToast();
   const [name, setName] = useState('');
   const [days, setDays] = useState<number>(DEFAULT_LIFETIME_DAYS);
-  const [scope, setScope] = useState<LongLivedTokenScope>('camera_stream');
+  const [scope, setScope] = useState<LongLivedTokenScope>(fixedScope ?? 'camera_stream');
   const [submitting, setSubmitting] = useState(false);
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
-    if (!name.trim()) return;
+    if (!name.trim() || submitting) return;
     setSubmitting(true);
+    onSubmittingChange?.(true);
     try {
       const created = await api.createLongLivedCameraToken({
         name: name.trim(),
         expires_in_days: days,
-        scope,
+        scope: fixedScope ?? scope,
       });
       onCreated(created);
       setName('');
       setDays(DEFAULT_LIFETIME_DAYS);
-      setScope('camera_stream');
+      setScope(fixedScope ?? 'camera_stream');
       showToast(t('cameraTokens.toast.created', 'Token created'));
     } catch (err) {
       showToast(
-        err instanceof Error ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
+        err instanceof Error && err.message ? err.message : t('cameraTokens.toast.createFailed', 'Failed to create token'),
         'error',
       );
     } finally {
       setSubmitting(false);
+      onSubmittingChange?.(false);
     }
   };
 
   return (
     <form
       onSubmit={handleSubmit}
-      className="bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
+      className="@container/token-form min-w-0 bg-bambu-dark-secondary rounded-lg p-4 mb-6 border border-bambu-dark-tertiary"
     >
       <h3 className="text-base font-semibold text-white mb-3">
         {t('cameraTokens.create.title', 'Create new token')}
       </h3>
-      <div className="grid gap-3 md:grid-cols-[1fr_180px_140px_auto]">
+      <div className="grid grid-cols-1 gap-3 @min-[26rem]/token-form:grid-cols-2 @min-[44rem]/token-form:grid-cols-[minmax(0,1fr)_180px_140px_auto]">
         <input
           type="text"
           maxLength={100}
@@ -90,13 +94,14 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
           value={name}
           onChange={(e) => setName(e.target.value)}
           placeholder={t('cameraTokens.create.namePlaceholder', 'e.g. Home Assistant')}
-          className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          className="min-w-0 w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
           aria-label={t('cameraTokens.create.nameLabel', 'Token name')}
         />
         <select
           value={scope}
+          disabled={!!fixedScope}
           onChange={(e) => setScope(e.target.value as LongLivedTokenScope)}
-          className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          className="min-w-0 w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
           aria-label={t('cameraTokens.create.scopeLabel', 'Scope')}
         >
           <option value="camera_stream">{t('cameraTokens.scope.camera_stream', 'Camera stream')}</option>
@@ -116,13 +121,13 @@ function CreateTokenForm({ onCreated }: CreateTokenFormProps) {
             // 400s on submit.
             setDays(Math.min(Math.max(next, 1), MAX_LIFETIME_DAYS));
           }}
-          className="px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
+          className="min-w-0 w-full px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none"
           aria-label={t('cameraTokens.create.daysLabel', 'Days until expiry')}
         />
         <button
           type="submit"
           disabled={submitting || !name.trim()}
-          className="flex items-center gap-2 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 disabled:opacity-50 disabled:cursor-not-allowed"
+          className="min-w-0 flex items-center justify-center gap-2 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 disabled:opacity-50 disabled:cursor-not-allowed"
         >
           <Plus className="w-4 h-4" />
           {t('cameraTokens.create.submit', 'Create')}
@@ -366,7 +371,7 @@ function TokenRow({ token, showOwner, ownerLabel, onRevoke }: TokenRowProps) {
   const expired = isExpired(token.expires_at);
   return (
     <tr className="border-b border-bambu-dark-tertiary last:border-b-0">
-      <td className="py-3 px-3 text-white">{token.name}</td>
+      <td className="py-3 px-3 text-white break-words max-w-64">{token.name}</td>
       {showOwner && <td className="py-3 px-3 text-bambu-gray">{ownerLabel}</td>}
       <td className="py-3 px-3">
         <span className="rounded bg-bambu-dark-tertiary px-2 py-0.5 text-xs text-bambu-gray">

+ 6 - 5
frontend/src/pages/SettingsPage.tsx

@@ -199,6 +199,7 @@ function describeLocationSensorValue(
 }
 
 export function SettingsPage() {
+  const [cameraTokenRevision, setCameraTokenRevision] = useState(0);
   const queryClient = useQueryClient();
   const navigate = useNavigate();
   const [searchParams, setSearchParams] = useSearchParams();
@@ -4444,13 +4445,13 @@ export function SettingsPage() {
       {/* API Keys Tab */}
       {activeTab === 'apikeys' && (
         <div className={hasPermission('api_keys:read')
-          ? 'grid grid-cols-1 xl:grid-cols-2 gap-4'
+          ? 'grid grid-cols-1 lg:grid-cols-2 gap-4'
           : 'grid grid-cols-1 gap-4'}>
           {/* Left Column - API Keys Management. Admin-gated content
               (webhook keys, webhook docs) is hidden from users without
               api_keys:read; the Camera Tokens panel is always shown so
               users with camera:view can self-manage their own tokens. */}
-          <div>
+          <div className="min-w-0">
             {hasPermission('api_keys:read') && <>
             <div className="flex items-start justify-between gap-4 mb-6">
               <div className="flex-1">
@@ -4896,7 +4897,7 @@ export function SettingsPage() {
                 </h3>
               </CardHeader>
               <CardContent>
-                <CameraTokensSection />
+                <CameraTokensSection key={cameraTokenRevision} />
               </CardContent>
             </Card>
 
@@ -4911,7 +4912,7 @@ export function SettingsPage() {
                 </h3>
               </CardHeader>
               <CardContent>
-                <StreamOverlayBuilder />
+                <StreamOverlayBuilder onTokenCreated={() => setCameraTokenRevision((revision) => revision + 1)} />
               </CardContent>
             </Card>
           </div>
@@ -4920,7 +4921,7 @@ export function SettingsPage() {
               api_keys:read since the API Browser is the testing surface
               for those keys; non-admins land in this tab only for the
               Camera Tokens panel and don't need the browser. */}
-          {hasPermission('api_keys:read') && <div>
+          {hasPermission('api_keys:read') && <div className="min-w-0">
             <div className="mb-6">
               <h2 className="text-lg font-semibold text-white flex items-center gap-2" id="card-apibrowser">
                 <Globe className="w-5 h-5 text-bambu-green" />

File diff suppressed because it is too large
+ 0 - 0
static/assets/ImagePreviewModal-B_nL3_8y.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/PdfPreviewModal-DFgmChOn.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/SpreadsheetPreviewModal-BZBKSNz6.js


+ 0 - 1
static/assets/chunk-aKtaBQYM.js

@@ -1 +0,0 @@
-var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,n)=>{let r={};for(var i in e)t(r,i,{get:e[i],enumerable:!0});return n||t(r,Symbol.toStringTag,{value:`Module`}),r},c=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;l<u;l++)d=c[l],!a.call(e,d)&&d!==o&&t(e,d,{get:(e=>i[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},l=(n,r,a)=>(a=n==null?{}:e(i(n)),c(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n)),u=(e=>typeof require<`u`?require:typeof Proxy<`u`?new Proxy(e,{get:(e,t)=>(typeof require<`u`?require:e)[t]}):e)(function(e){if(typeof require<`u`)return require.apply(this,arguments);throw Error('Calling `require` for "'+e+"\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.")});export{l as i,s as n,u as r,o as t};

File diff suppressed because it is too large
+ 0 - 1
static/assets/index-BljpHSYg.js


File diff suppressed because it is too large
+ 0 - 1
static/assets/index-CaVfzyDI.css


BIN
static/assets/occt-import-js-BhHfLpto.wasm


File diff suppressed because it is too large
+ 0 - 0
static/assets/papaparse.min-CR43Mv99.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf-KdLife5c.js


File diff suppressed because it is too large
+ 0 - 0
static/assets/pdf.worker.min-0idpi3xP.js


+ 0 - 1
static/assets/pdf.worker.min-DpOxPwGP.js

@@ -1 +0,0 @@
-var e=`/assets/pdf.worker.min-0idpi3xP.js`;export{e as default};

BIN
static/assets/pdfjs/cmaps/78-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/78-V.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/78ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/83pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90ms-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90msp-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/90pv-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-H.bcmap


BIN
static/assets/pdfjs/cmaps/Add-RKSJ-V.bcmap


BIN
static/assets/pdfjs/cmaps/Add-V.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-CNS1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-GB1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-3.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-4.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-5.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-6.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Japan1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-0.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-1.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-2.bcmap


BIN
static/assets/pdfjs/cmaps/Adobe-Korea1-UCS2.bcmap


BIN
static/assets/pdfjs/cmaps/B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-H.bcmap


BIN
static/assets/pdfjs/cmaps/B5pc-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS-EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-H.bcmap


BIN
static/assets/pdfjs/cmaps/CNS1-V.bcmap


BIN
static/assets/pdfjs/cmaps/CNS2-H.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/CNS2-V.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEáCNS2-H

BIN
static/assets/pdfjs/cmaps/ETHK-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETHK-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-H.bcmap


BIN
static/assets/pdfjs/cmaps/ETen-B5-V.bcmap


+ 0 - 3
static/assets/pdfjs/cmaps/ETenms-B5-H.bcmap

@@ -1,3 +0,0 @@
-àRCopyright 1990-2009 Adobe Systems Incorporated.
-All rights reserved.
-See ./LICENSEá	ETen-B5-H` ^

BIN
static/assets/pdfjs/cmaps/ETenms-B5-V.bcmap


BIN
static/assets/pdfjs/cmaps/EUC-H.bcmap


BIN
static/assets/pdfjs/cmaps/EUC-V.bcmap


BIN
static/assets/pdfjs/cmaps/Ext-H.bcmap


Some files were not shown because too many files changed in this diff