Browse Source

fix(vp): auto-derive access code from target printer in non-proxy modes

  Non-proxy VPs (Archive / Review / Queue) with a target printer set up
  a live-mirror bridge that forwards the slicer's MQTT and RTSPS auth
  bytes through to the real printer. The slicer holds one code in its
  profile (the one it bound the VP with), and that code has to satisfy
  both the VP listener and the real printer at the far end of the
  bridge. If the codes diverge the bridge silently fails at the second
  hop — slicer reaches .49:8883, FINs before sending a ClientHello,
  retries identically. The wiki framed the code-match requirement as a
  camera-only concern; it isn't, all bridged protocols inherit.

  Fix removes the foot-gun instead of re-documenting it. When a target
  is selected on a non-proxy VP the access-code field switches to a
  read-only display showing the target's code with an Eye-toggle
  reveal; the backend auto-inherits on every create / update (any
  explicit access_code submitted alongside a target is silently
  overridden as belt-and-braces for non-UI clients). The required-when-
  enabling check now treats target-set as satisfying the access-code
  requirement. Standalone (no-target) non-proxy VPs still get the
  editable input + Save button.

  One-shot startup migration corrects any pre-existing mismatched
  rows: SELECTs diverged VPs and logs one INFO line per row for the
  audit trail, then UPDATEs via correlated subquery. Idempotent and
  portable between SQLite and Postgres.
maziggy 3 tháng trước cách đây
mục cha
commit
19073f5c84

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 0
CHANGELOG.md


+ 30 - 3
backend/app/api/routes/virtual_printers.py

@@ -154,7 +154,10 @@ async def create_virtual_printer(
     if body.access_code and len(body.access_code) != 8:
         return JSONResponse(status_code=400, content={"detail": "Access code must be exactly 8 characters"})
 
-    # Validation when enabling
+    # Validation when enabling. Non-proxy VPs with a target printer derive
+    # their access code from the target (the bridge forwards the slicer's
+    # auth bytes through to the real printer, so the codes MUST match),
+    # so a separately-supplied access_code isn't required in that case.
     if body.enabled:
         if not body.bind_ip:
             return JSONResponse(status_code=400, content={"detail": "Bind IP is required when enabling"})
@@ -162,7 +165,7 @@ async def create_virtual_printer(
             if not body.target_printer_id:
                 return JSONResponse(status_code=400, content={"detail": "Target printer is required for proxy mode"})
         else:
-            if not body.access_code:
+            if not body.access_code and not body.target_printer_id:
                 return JSONResponse(status_code=400, content={"detail": "Access code is required when enabling"})
 
     # Validate proxy target printer exists
@@ -188,6 +191,16 @@ async def create_virtual_printer(
         if result.scalar_one_or_none():
             return JSONResponse(status_code=400, content={"detail": f"Bind IP {body.bind_ip} is already in use"})
 
+    # Force-inherit the access code from the target printer for non-proxy VPs.
+    # The non-proxy bridge (Immediate / Review / Queue with a target set) forwards
+    # the slicer's MQTT / RTSPS auth bytes through to the real printer, so any
+    # value the user supplied here would silently break the bridge if it didn't
+    # match the printer's code. The UI now renders the field read-only when a
+    # target is set; this is the belt-and-braces backstop for any non-UI client.
+    effective_access_code = body.access_code
+    if body.mode != "proxy" and target_printer is not None:
+        effective_access_code = target_printer.access_code
+
     # Generate next serial suffix
     result = await db.execute(select(VirtualPrinter.serial_suffix).order_by(VirtualPrinter.id.desc()))
     last_suffix = result.scalar()
@@ -212,7 +225,7 @@ async def create_virtual_printer(
         model=body.model
         or _resolve_printer_model(target_printer.model if target_printer and body.mode == "proxy" else None)
         or DEFAULT_VIRTUAL_PRINTER_MODEL,
-        access_code=body.access_code,
+        access_code=effective_access_code,
         target_printer_id=body.target_printer_id,
         auto_dispatch=body.auto_dispatch,
         queue_force_color_match=body.queue_force_color_match,
@@ -409,6 +422,20 @@ async def update_virtual_printer(
         if existing_target and existing_target.model:
             vp.model = _resolve_printer_model(existing_target.model) or existing_target.model
 
+    # Force-inherit the access code from the target printer for non-proxy VPs.
+    # See create_virtual_printer for the rationale: the bridge forwards slicer
+    # auth bytes through, so the VP's code MUST equal the target's. This block
+    # runs after every patch (whether or not access_code or target were in the
+    # body), so changing the target also resyncs the code, and an explicit
+    # access_code submitted alongside a target is silently overridden.
+    if vp.mode != "proxy" and vp.target_printer_id is not None:
+        from backend.app.models.printer import Printer as PrinterModelAC
+
+        result = await db.execute(select(PrinterModelAC).where(PrinterModelAC.id == vp.target_printer_id))
+        target_for_ac = result.scalar_one_or_none()
+        if target_for_ac is not None and vp.access_code != target_for_ac.access_code:
+            vp.access_code = target_for_ac.access_code
+
     # Determine final enabled state
     explicitly_enabling = body.enabled is True
     new_enabled = body.enabled if body.enabled is not None else vp.enabled

+ 40 - 0
backend/app/core/database.py

@@ -1833,6 +1833,46 @@ async def run_migrations(conn):
             {"old": old_val, "new": new_val},
         )
 
+    # Migration: Auto-sync VP access codes from their target printer.
+    # Non-proxy VPs with a target printer (the live-mirror bridge) forward the
+    # slicer's MQTT/RTSPS auth bytes through to the real printer, so the VP's
+    # access code MUST equal the target's — earlier UIs let them diverge,
+    # producing a VP that the slicer could bind but whose bridge silently
+    # failed to authenticate against the real printer. The route layer now
+    # auto-inherits on every create/update; this backfill corrects any rows
+    # that pre-date that change. Idempotent (re-running on synced rows is a
+    # no-op because the WHERE clause excludes them). SQLite and Postgres both
+    # accept correlated subqueries in UPDATE — no driver-specific syntax.
+    mismatch_result = await conn.execute(
+        text(
+            "SELECT vp.id AS vp_id, vp.name AS vp_name, p.name AS target_name "
+            "FROM virtual_printers vp "
+            "JOIN printers p ON vp.target_printer_id = p.id "
+            "WHERE vp.mode != 'proxy' "
+            "  AND (vp.access_code IS NULL OR vp.access_code != p.access_code)"
+        )
+    )
+    for row in mismatch_result.fetchall():
+        logger.info(
+            "VP %r (id=%d) access code synced from target printer %r",
+            row.vp_name,
+            row.vp_id,
+            row.target_name,
+        )
+    await conn.execute(
+        text(
+            "UPDATE virtual_printers "
+            "SET access_code = ("
+            "    SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
+            ") "
+            "WHERE virtual_printers.target_printer_id IS NOT NULL "
+            "  AND virtual_printers.mode != 'proxy' "
+            "  AND (virtual_printers.access_code IS NULL OR virtual_printers.access_code != ("
+            "      SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
+            "  ))"
+        )
+    )
+
     # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
     # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
     # sliced outputs — the upload, ZIP-extract, and in-process paths all

+ 147 - 0
backend/tests/integration/test_virtual_printer_api.py

@@ -452,3 +452,150 @@ class TestVirtualPrinterDiagnosticAPI:
         by_id = {c["id"]: c["status"] for c in result["checks"]}
         assert by_id["enabled"] == "fail"
         assert by_id["running"] == "skip"
+
+
+class TestVirtualPrinterAccessCodeInheritance:
+    """Non-proxy VPs with a target printer must inherit the target's access
+    code at write time.
+
+    The live-mirror bridge forwards the slicer's MQTT/RTSPS auth bytes to
+    the real printer — if the codes diverge the slicer binds the VP but the
+    bridge fails at the second hop. The route layer force-derives the code
+    on every create / update so a non-UI client can't introduce a divergence
+    either.
+    """
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_target_ignores_submitted_access_code(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "access_code": "WRONGAAA",
+                "target_printer_id": target.id,
+            },
+        )
+        assert response.status_code == 200
+        vp_id = response.json()["id"]
+        assert response.json()["access_code_set"] is True
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "REALCODE"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_with_target_and_no_access_code_still_enables(
+        self, async_client: AsyncClient, printer_factory
+    ):
+        """A non-proxy VP with a target set can be enabled without supplying
+        access_code separately — the inheritance makes the explicit field
+        redundant, and the validator now knows this."""
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "QueueVP",
+                "mode": "queue",
+                "target_printer_id": target.id,
+                "bind_ip": "192.168.1.50",
+                "enabled": True,
+            },
+        )
+        assert response.status_code == 200
+        assert response.json()["access_code_set"] is True
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_create_without_target_still_requires_access_code_on_enable(self, async_client: AsyncClient):
+        """The relaxation only kicks in when a target is set. A standalone
+        non-proxy VP still needs its own access code."""
+        response = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "StandaloneVP",
+                "mode": "archive",
+                "bind_ip": "192.168.1.51",
+                "enabled": True,
+            },
+        )
+        assert response.status_code == 400
+        assert "access code" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_target_resyncs_access_code(self, async_client: AsyncClient, printer_factory, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        first = await printer_factory(name="Printer A", access_code="AAAAAAAA")
+        second = await printer_factory(name="Printer B", access_code="BBBBBBBB")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "MovingTarget",
+                "mode": "queue",
+                "target_printer_id": first.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "AAAAAAAA"
+
+        # Repoint to the second printer — access code should follow.
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"target_printer_id": second.id},
+        )
+        assert update_resp.status_code == 200
+
+        await db_session.refresh(vp)
+        assert vp.access_code == "BBBBBBBB"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_update_explicit_access_code_with_target_is_overridden(
+        self, async_client: AsyncClient, printer_factory, db_session
+    ):
+        """An update that submits both an explicit access_code AND keeps a
+        target_printer_id silently uses the target's code — belt-and-braces
+        for non-UI clients that might try to set a divergent value."""
+        from sqlalchemy import select
+
+        from backend.app.models.virtual_printer import VirtualPrinter
+
+        target = await printer_factory(name="Real X1C", access_code="REALCODE")
+
+        create_resp = await async_client.post(
+            "/api/v1/virtual-printers",
+            json={
+                "name": "BeltBraces",
+                "mode": "queue",
+                "target_printer_id": target.id,
+            },
+        )
+        assert create_resp.status_code == 200
+        vp_id = create_resp.json()["id"]
+
+        update_resp = await async_client.put(
+            f"/api/v1/virtual-printers/{vp_id}",
+            json={"access_code": "FORGEDCD"},
+        )
+        assert update_resp.status_code == 200
+
+        vp = (await db_session.execute(select(VirtualPrinter).where(VirtualPrinter.id == vp_id))).scalar_one()
+        assert vp.access_code == "REALCODE"

+ 238 - 0
backend/tests/unit/test_vp_access_code_sync_migration.py

@@ -0,0 +1,238 @@
+"""Regression test for the VP access-code sync migration.
+
+Non-proxy VPs with a target printer must use the target's access code
+because the live-mirror bridge forwards the slicer's MQTT/RTSPS auth
+bytes through to the real printer. Earlier UIs let the codes diverge,
+producing a VP whose listener accepted the bind but whose bridge then
+failed at the second hop. The migration in ``run_migrations`` rewrites
+mismatched rows on the next boot after upgrade.
+"""
+
+from __future__ import annotations
+
+import pytest
+from sqlalchemy import text
+from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
+
+from backend.app.core.database import run_migrations
+
+
+@pytest.fixture(autouse=True)
+def force_sqlite_dialect(monkeypatch):
+    """Force the SQLite branch regardless of test env settings."""
+    from backend.app.core import db_dialect
+
+    monkeypatch.setattr(db_dialect, "is_sqlite", lambda: True)
+    monkeypatch.setattr(db_dialect, "is_postgres", lambda: False)
+    from backend.app.core import database as database_module
+
+    monkeypatch.setattr(database_module, "is_sqlite", lambda: True)
+
+
+def _register_all_models():
+    """run_migrations touches multiple tables; the full schema must exist."""
+    from backend.app.models import (  # noqa: F401
+        ams_history,
+        ams_label,
+        api_key,
+        archive,
+        color_catalog,
+        external_link,
+        filament,
+        group,
+        kprofile_note,
+        maintenance,
+        notification,
+        notification_template,
+        print_log,
+        print_queue,
+        printer,
+        project,
+        project_bom,
+        settings,
+        slot_preset,
+        smart_plug,
+        smart_plug_energy_snapshot,
+        spool,
+        spool_assignment,
+        spool_catalog,
+        spool_k_profile,
+        spool_usage_history,
+        spoolbuddy_device,
+        user,
+        user_email_pref,
+        virtual_printer,
+    )
+
+
+@pytest.fixture
+async def engine():
+    from backend.app.core.database import Base
+
+    _register_all_models()
+
+    eng = create_async_engine("sqlite+aiosqlite:///:memory:", echo=False)
+    async with eng.begin() as conn:
+        await conn.run_sync(Base.metadata.create_all)
+    yield eng
+    await eng.dispose()
+
+
+async def _seed_printer(engine, printer_id: int, name: str, access_code: str) -> None:
+    """Insert a printer row through the ORM so Python-side defaults
+    (nozzle_count, is_active, auto_archive, print_hours_offset, …) all apply
+    without us having to mirror every NOT NULL column in raw SQL."""
+    from backend.app.models.printer import Printer
+
+    async with AsyncSession(engine) as session:
+        session.add(
+            Printer(
+                id=printer_id,
+                name=name,
+                ip_address=f"192.168.1.{printer_id + 100}",
+                access_code=access_code,
+                serial_number=f"01P00A39180000{printer_id}",
+                model="C12",
+            )
+        )
+        await session.commit()
+
+
+@pytest.mark.asyncio
+async def test_non_proxy_vp_with_target_inherits_access_code(engine):
+    """A non-proxy VP with a mismatched access_code gets corrected to match
+    the target printer's code on the next boot."""
+    await _seed_printer(engine, 1, "Real X1C", "REALCODE")
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES (1, 'Queue VP', 0, 'queue', 'OLDVPCDE', 1, '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
+    assert code == "REALCODE"
+
+
+@pytest.mark.asyncio
+async def test_proxy_vp_access_code_is_left_alone(engine):
+    """Proxy-mode VPs are NOT touched — the proxy already uses the target's
+    code transparently at the protocol level, and the model column can
+    legitimately hold an unused access_code value."""
+    await _seed_printer(engine, 1, "Real X1C", "REALCODE")
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES (1, 'Proxy VP', 0, 'proxy', 'PROXYCDE', 1, '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
+    assert code == "PROXYCDE"
+
+
+@pytest.mark.asyncio
+async def test_already_matching_vp_is_left_alone(engine):
+    """A VP whose code already equals the target's needs no change.
+    Confirms the WHERE clause excludes synced rows so re-running is a no-op."""
+    await _seed_printer(engine, 1, "Real X1C", "MATCHED1")
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES (1, 'Synced VP', 0, 'archive', 'MATCHED1', 1, '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+    # Re-run to prove idempotency.
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
+    assert code == "MATCHED1"
+
+
+@pytest.mark.asyncio
+async def test_non_proxy_vp_without_target_is_left_alone(engine):
+    """No target = no bridge = nothing to derive from. The VP keeps its own code."""
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES (1, 'Standalone VP', 0, 'archive', 'STANDALN', NULL, '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
+    assert code == "STANDALN"
+
+
+@pytest.mark.asyncio
+async def test_null_vp_access_code_with_target_gets_populated(engine):
+    """A VP with no access_code at all (NULL) but a target set is treated
+    as a divergence — the migration populates it from the target."""
+    await _seed_printer(engine, 1, "Real X1C", "FRESHCDE")
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES (1, 'Fresh VP', 0, 'queue', NULL, 1, '391800001', 1)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        code = (await conn.execute(text("SELECT access_code FROM virtual_printers WHERE id = 1"))).scalar()
+    assert code == "FRESHCDE"
+
+
+@pytest.mark.asyncio
+async def test_multi_vp_sync_one_run(engine):
+    """Multiple mismatched VPs against different targets are all corrected
+    in a single migration pass."""
+    await _seed_printer(engine, 1, "Printer A", "AAAAAAAA")
+    await _seed_printer(engine, 2, "Printer B", "BBBBBBBB")
+    async with engine.begin() as conn:
+        await conn.execute(
+            text(
+                "INSERT INTO virtual_printers "
+                "(id, name, enabled, mode, access_code, target_printer_id, serial_suffix, position) "
+                "VALUES "
+                "(1, 'VP-A', 0, 'archive', 'WRONGAAA', 1, '391800001', 1),"
+                "(2, 'VP-B', 0, 'queue', 'WRONGBBB', 2, '391800002', 2)"
+            )
+        )
+
+    async with engine.begin() as conn:
+        await run_migrations(conn)
+
+    async with engine.connect() as conn:
+        result = await conn.execute(text("SELECT id, access_code FROM virtual_printers ORDER BY id"))
+        rows = dict(result.fetchall())
+
+    assert rows[1] == "AAAAAAAA"
+    assert rows[2] == "BBBBBBBB"

+ 80 - 1
frontend/src/__tests__/components/VirtualPrinterCard.test.tsx

@@ -35,7 +35,7 @@ vi.mock('../../api/client', () => ({
   },
 }));
 
-import { multiVirtualPrinterApi } from '../../api/client';
+import { multiVirtualPrinterApi, api } from '../../api/client';
 
 const models: Record<string, string> = {
   'BL-P001': 'X1C',
@@ -407,3 +407,82 @@ describe('VirtualPrinterCard - Tailscale FQDN copy', () => {
     });
   });
 });
+
+// Non-proxy VPs with a target printer derive their access code from the
+// target — the live-mirror bridge forwards slicer auth to the real printer,
+// so the codes must match. The card surfaces the target's code read-only
+// (with an Eye-toggle reveal) so the user knows what to type into the slicer
+// but can't diverge it from the printer's. When no target is set, the field
+// stays editable.
+describe('VirtualPrinterCard - access code inherits from target', () => {
+  const printers = [
+    {
+      id: 7,
+      name: 'Workshop X1C',
+      ip_address: '192.168.1.50',
+      access_code: 'TGTCODE1',
+      serial_number: '01P00A391800001',
+      model: 'X1C',
+      is_active: true,
+    },
+  ];
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(multiVirtualPrinterApi.update).mockResolvedValue(createMockPrinter());
+    // Re-mock the printers query for this block so the card has a target
+    // printer it can read access_code from.
+    vi.mocked(api.getPrinters).mockResolvedValue(printers as unknown as Awaited<ReturnType<typeof api.getPrinters>>);
+  });
+
+  it('shows target printer access code read-only when target is set on a non-proxy VP', async () => {
+    const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    // Wait for the inheritance badge AND the actual code value to appear —
+    // the badge renders synchronously from local state, but the value
+    // depends on the printers query (api.getPrinters) resolving first.
+    const codeInput = await waitFor(() => {
+      const input = screen.getByLabelText('Access Code') as HTMLInputElement;
+      if (input.value !== 'TGTCODE1') throw new Error('inherited value not populated yet');
+      return input;
+    });
+
+    expect(screen.getByText('Inherited from target')).toBeInTheDocument();
+    // Save button must NOT exist in the readonly path — the field is
+    // managed via the target printer's settings, not this card.
+    expect(screen.queryByRole('button', { name: /save/i })).not.toBeInTheDocument();
+    expect(codeInput.readOnly).toBe(true);
+    expect(codeInput.type).toBe('password');
+  });
+
+  it('toggles the access code to plaintext via the Eye button', async () => {
+    const user = userEvent.setup();
+    const printer = createMockPrinter({ mode: 'queue', target_printer_id: 7 });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByText('Inherited from target')).toBeInTheDocument();
+    });
+
+    const revealBtn = screen.getByRole('button', { name: /show access code/i });
+    await user.click(revealBtn);
+
+    const codeInput = screen.getByLabelText('Access Code') as HTMLInputElement;
+    expect(codeInput.type).toBe('text');
+  });
+
+  it('keeps the editable input + Save button when no target is set', async () => {
+    const printer = createMockPrinter({ mode: 'archive', target_printer_id: null });
+    render(<VirtualPrinterCard printer={printer} models={models} />);
+
+    await waitFor(() => {
+      expect(screen.getByPlaceholderText('Enter 8-char code')).toBeInTheDocument();
+    });
+
+    // Inheritance badge must NOT appear when there's no target.
+    expect(screen.queryByText('Inherited from target')).not.toBeInTheDocument();
+    // Save button IS present in the editable path (disabled until 8 chars typed).
+    expect(screen.getByRole('button', { name: /save/i })).toBeInTheDocument();
+  });
+});

+ 87 - 34
frontend/src/components/VirtualPrinterCard.tsx

@@ -206,9 +206,23 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
   };
 
   const handleTargetPrinterChange = (printerId: number) => {
+    // The new target's access code becomes this VP's access code on the
+    // backend write. If the slicer was already bound with the old code,
+    // it has to rebind; flag this so the user doesn't sit there confused.
+    const previousCode = targetPrinter?.access_code;
+    const nextCode = printers?.find(p => p.id === printerId)?.access_code;
     setLocalTargetPrinterId(printerId);
     setPendingAction('targetPrinter');
-    updateMutation.mutate({ target_printer_id: printerId });
+    updateMutation.mutate(
+      { target_printer_id: printerId },
+      {
+        onSuccess: () => {
+          if (previousCode && nextCode && previousCode !== nextCode) {
+            showToast(t('virtualPrinter.toast.targetCodeChangedRebind'), 'info');
+          }
+        },
+      },
+    );
   };
 
   const handleRemoteInterfaceChange = (ip: string) => {
@@ -219,7 +233,15 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
 
   const isRunning = printer.status?.running || false;
   const modeLabel = t(`virtualPrinter.mode.${MODE_LABELS[localMode] || 'archive'}`);
-  const targetPrinterName = printers?.find(p => p.id === localTargetPrinterId)?.name;
+  const targetPrinter = printers?.find(p => p.id === localTargetPrinterId);
+  const targetPrinterName = targetPrinter?.name;
+  // The bridge in non-proxy modes (and the transparent relay in proxy mode)
+  // forwards the slicer's auth bytes to the real printer, so the VP's access
+  // code is always the target's. When a target is set, the card surfaces the
+  // target's code read-only — the user types it into the slicer, but can't
+  // diverge it from the printer.
+  const inheritsAccessCodeFromTarget = !!localTargetPrinterId;
+  const inheritedAccessCode = inheritsAccessCodeFromTarget ? (targetPrinter?.access_code ?? '') : '';
 
   return (
     <>
@@ -481,7 +503,12 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
               <div className="pt-2 border-t border-bambu-dark-tertiary">
                 <div className="flex items-center gap-2 mb-2">
                   <div className="text-white text-sm font-medium">{t('virtualPrinter.accessCode.title')}</div>
-                  {printer.access_code_set ? (
+                  {inheritsAccessCodeFromTarget ? (
+                    <span className="flex items-center gap-1 text-xs text-blue-400">
+                      <Info className="w-3 h-3" />
+                      {t('virtualPrinter.accessCode.inheritedFromTarget')}
+                    </span>
+                  ) : printer.access_code_set ? (
                     <span className="flex items-center gap-1 text-xs text-green-400">
                       <Check className="w-3 h-3" />
                       {t('virtualPrinter.accessCode.isSet')}
@@ -493,37 +520,63 @@ export function VirtualPrinterCard({ printer, models }: VirtualPrinterCardProps)
                     </span>
                   )}
                 </div>
-                <div className="flex gap-2">
-                  <div className="relative flex-1">
-                    <input
-                      type={showAccessCode ? 'text' : 'password'}
-                      value={localAccessCode}
-                      onChange={(e) => setLocalAccessCode(e.target.value)}
-                      placeholder={printer.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
-                      maxLength={8}
-                      className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono"
-                    />
-                    <button
-                      onClick={() => setShowAccessCode(!showAccessCode)}
-                      className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
-                    >
-                      {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
-                    </button>
-                  </div>
-                  <Button
-                    onClick={handleAccessCodeChange}
-                    disabled={!localAccessCode || pendingAction === 'accessCode'}
-                    variant="primary"
-                  >
-                    {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
-                  </Button>
-                </div>
-                {localAccessCode && (
-                  <p className="text-xs text-bambu-gray mt-1">
-                    <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
-                      {t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
-                    </span>
-                  </p>
+                {inheritsAccessCodeFromTarget ? (
+                  <>
+                    <div className="relative">
+                      <input
+                        type={showAccessCode ? 'text' : 'password'}
+                        value={inheritedAccessCode}
+                        readOnly
+                        aria-label={t('virtualPrinter.accessCode.title')}
+                        className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm pr-10 font-mono opacity-90 cursor-default"
+                      />
+                      <button
+                        onClick={() => setShowAccessCode(!showAccessCode)}
+                        className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                        aria-label={showAccessCode ? t('virtualPrinter.accessCode.hide') : t('virtualPrinter.accessCode.reveal')}
+                      >
+                        {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                      </button>
+                    </div>
+                    <p className="text-xs text-bambu-gray mt-1">
+                      {t('virtualPrinter.accessCode.derivedFromTargetHint')}
+                    </p>
+                  </>
+                ) : (
+                  <>
+                    <div className="flex gap-2">
+                      <div className="relative flex-1">
+                        <input
+                          type={showAccessCode ? 'text' : 'password'}
+                          value={localAccessCode}
+                          onChange={(e) => setLocalAccessCode(e.target.value)}
+                          placeholder={printer.access_code_set ? t('virtualPrinter.accessCode.placeholderChange') : t('virtualPrinter.accessCode.placeholder')}
+                          maxLength={8}
+                          className="w-full bg-bambu-dark-secondary border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-white text-sm placeholder-bambu-gray pr-10 font-mono"
+                        />
+                        <button
+                          onClick={() => setShowAccessCode(!showAccessCode)}
+                          className="absolute right-2 top-1/2 -translate-y-1/2 text-bambu-gray hover:text-white"
+                        >
+                          {showAccessCode ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
+                        </button>
+                      </div>
+                      <Button
+                        onClick={handleAccessCodeChange}
+                        disabled={!localAccessCode || pendingAction === 'accessCode'}
+                        variant="primary"
+                      >
+                        {pendingAction === 'accessCode' ? <Loader2 className="w-4 h-4 animate-spin" /> : t('common.save')}
+                      </Button>
+                    </div>
+                    {localAccessCode && (
+                      <p className="text-xs text-bambu-gray mt-1">
+                        <span className={localAccessCode.length === 8 ? 'text-green-400' : 'text-yellow-400'}>
+                          {t('virtualPrinter.accessCode.charCount', { count: localAccessCode.length })}
+                        </span>
+                      </p>
+                    )}
+                  </>
                 )}
               </div>
             )}

+ 5 - 0
frontend/src/i18n/locales/de.ts

@@ -4416,6 +4416,10 @@ export default {
       placeholderChange: 'Neuen Code eingeben zum Ändern',
       hint: 'Muss genau 8 Zeichen lang sein. Wird von Slicern zur Authentifizierung verwendet.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Vom Zieldrucker übernommen',
+      derivedFromTargetHint: 'Verwendet den Zugangscode des Zieldruckers. Die Brücke leitet die Slicer-Authentifizierung an den echten Drucker weiter, daher müssen die Codes übereinstimmen — den Druckercode in dessen Einstellungen ändern.',
+      reveal: 'Zugangscode anzeigen',
+      hide: 'Zugangscode verbergen',
     },
     targetPrinter: {
       title: 'Zieldrucker',
@@ -4502,6 +4506,7 @@ export default {
       bindIpRequired: 'Bitte zuerst eine Bind-IP setzen',
       accessCodeEmpty: 'Zugangscode darf nicht leer sein',
       accessCodeLength: 'Zugangscode muss genau 8 Zeichen lang sein',
+      targetCodeChangedRebind: 'Zugangscode wurde an den neuen Zieldrucker angepasst. Bitte dieses Gerät im Slicer neu hinzufügen, damit der neue Code übernommen wird.',
       created: 'Virtueller Drucker erstellt',
       failedToCreate: 'Virtueller Drucker konnte nicht erstellt werden',
       deleted: 'Virtueller Drucker gelöscht',

+ 5 - 0
frontend/src/i18n/locales/en.ts

@@ -4429,6 +4429,10 @@ export default {
       placeholderChange: 'Enter new code to change',
       hint: 'Must be exactly 8 characters. Used by slicers to authenticate.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Inherited from target',
+      derivedFromTargetHint: 'Uses the target printer\'s access code. The bridge forwards slicer auth to the real printer, so the codes must match — edit the printer\'s access code to change this value.',
+      reveal: 'Show access code',
+      hide: 'Hide access code',
     },
     targetPrinter: {
       title: 'Target Printer',
@@ -4515,6 +4519,7 @@ export default {
       bindIpRequired: 'Please set a bind IP first',
       accessCodeEmpty: 'Access code cannot be empty',
       accessCodeLength: 'Access code must be exactly 8 characters',
+      targetCodeChangedRebind: 'Access code now matches the new target printer. Re-add this device in your slicer to pick up the new code.',
       created: 'Virtual printer created',
       failedToCreate: 'Failed to create virtual printer',
       deleted: 'Virtual printer deleted',

+ 5 - 0
frontend/src/i18n/locales/es.ts

@@ -4425,6 +4425,10 @@ export default {
       placeholderChange: 'Introduzca un nuevo código para cambiarlo',
       hint: 'Debe tener exactamente 8 caracteres. Los laminadores lo usan para autenticarse.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Heredado de la impresora de destino',
+      derivedFromTargetHint: 'Usa el código de acceso de la impresora de destino. El puente reenvía la autenticación del laminador a la impresora real, por lo que los códigos deben coincidir — edita el código en los ajustes de la impresora para cambiarlo.',
+      reveal: 'Mostrar código de acceso',
+      hide: 'Ocultar código de acceso',
     },
     targetPrinter: {
       title: 'Impresora de destino',
@@ -4511,6 +4515,7 @@ export default {
       bindIpRequired: 'Establezca primero una IP de enlace',
       accessCodeEmpty: 'El código de acceso no puede estar vacío',
       accessCodeLength: 'El código de acceso debe tener exactamente 8 caracteres',
+      targetCodeChangedRebind: 'El código de acceso ahora coincide con la nueva impresora de destino. Vuelve a añadir este dispositivo en tu laminador para usar el nuevo código.',
       created: 'Impresora virtual creada',
       failedToCreate: 'Error al crear la impresora virtual',
       deleted: 'Impresora virtual eliminada',

+ 5 - 0
frontend/src/i18n/locales/fr.ts

@@ -4406,6 +4406,10 @@ export default {
       placeholderChange: 'Entrez nouveau code',
       hint: 'Exactement 8 caractères. Sert à l\'auth du Slicer.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Hérité de l\'imprimante cible',
+      derivedFromTargetHint: 'Utilise le code d\'accès de l\'imprimante cible. Le pont transmet l\'authentification du slicer à l\'imprimante réelle, donc les codes doivent correspondre — modifiez le code dans les paramètres de l\'imprimante.',
+      reveal: 'Afficher le code d\'accès',
+      hide: 'Masquer le code d\'accès',
     },
     targetPrinter: {
       title: 'Imprimante cible',
@@ -4478,6 +4482,7 @@ export default {
       bindIpRequired: 'Veuillez d\'abord définir une adresse IP',
       accessCodeEmpty: 'Le code ne peut pas être vide',
       accessCodeLength: 'Le code doit faire 8 caractères',
+      targetCodeChangedRebind: 'Le code d\'accès correspond désormais à la nouvelle imprimante cible. Réajoutez cet appareil dans votre slicer pour récupérer le nouveau code.',
       created: 'Imprimante virtuelle créée',
       failedToCreate: 'Échec de la création de l\'imprimante virtuelle',
       deleted: 'Imprimante virtuelle supprimée',

+ 5 - 0
frontend/src/i18n/locales/it.ts

@@ -4405,6 +4405,10 @@ export default {
       placeholderChange: 'Inserisci nuovo codice per cambiare',
       hint: 'Deve essere esattamente 8 caratteri. Usato dagli slicer per autenticarsi.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Ereditato dalla stampante target',
+      derivedFromTargetHint: 'Usa il codice di accesso della stampante target. Il bridge inoltra l\'autenticazione dello slicer alla stampante reale, quindi i codici devono corrispondere — modifica il codice nelle impostazioni della stampante per cambiarlo.',
+      reveal: 'Mostra codice accesso',
+      hide: 'Nascondi codice accesso',
     },
     targetPrinter: {
       title: 'Stampante target',
@@ -4477,6 +4481,7 @@ export default {
       bindIpRequired: 'Impostare prima un indirizzo IP',
       accessCodeEmpty: 'Il codice accesso non può essere vuoto',
       accessCodeLength: 'Il codice accesso deve essere esattamente 8 caratteri',
+      targetCodeChangedRebind: 'Il codice di accesso ora corrisponde alla nuova stampante target. Ri-aggiungi questo dispositivo nel tuo slicer per acquisire il nuovo codice.',
       created: 'Stampante virtuale creata',
       failedToCreate: 'Impossibile creare la stampante virtuale',
       deleted: 'Stampante virtuale eliminata',

+ 5 - 0
frontend/src/i18n/locales/ja.ts

@@ -4417,6 +4417,10 @@ export default {
       placeholderChange: '新しいコードを入力して変更',
       hint: '正確に8文字必要です。スライサーの認証に使用されます。',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'ターゲットプリンターから継承',
+      derivedFromTargetHint: 'ターゲットプリンターのアクセスコードを使用します。ブリッジはスライサーの認証情報を実機プリンターに転送するため、コードが一致している必要があります。変更するにはプリンターの設定でアクセスコードを編集してください。',
+      reveal: 'アクセスコードを表示',
+      hide: 'アクセスコードを隠す',
     },
     targetPrinter: {
       title: 'ターゲットプリンター',
@@ -4489,6 +4493,7 @@ export default {
       bindIpRequired: '先にバインドIPを設定してください',
       accessCodeEmpty: 'アクセスコードは空にできません',
       accessCodeLength: 'アクセスコードは8文字である必要があります',
+      targetCodeChangedRebind: 'アクセスコードが新しいターゲットプリンターに同期されました。新しいコードを反映するには、スライサーでこのデバイスを再登録してください。',
       created: '仮想プリンターを作成しました',
       failedToCreate: '仮想プリンターの作成に失敗しました',
       deleted: '仮想プリンターを削除しました',

+ 6 - 1
frontend/src/i18n/locales/ko.ts

@@ -4155,7 +4155,11 @@ export default {
       placeholder: '8자리 코드 입력',
       placeholderChange: '변경하려면 새 코드 입력',
       hint: '정확히 8자여야 합니다. 슬라이서가 인증하는 데 사용됩니다.',
-      charCount: '({{count}}/8)'
+      charCount: '({{count}}/8)',
+      inheritedFromTarget: '대상 프린터에서 상속됨',
+      derivedFromTargetHint: '대상 프린터의 액세스 코드를 사용합니다. 브리지는 슬라이서 인증을 실제 프린터로 전달하므로 코드가 일치해야 합니다 — 변경하려면 프린터 설정에서 액세스 코드를 편집하세요.',
+      reveal: '액세스 코드 표시',
+      hide: '액세스 코드 숨기기'
     },
     targetPrinter: {
       title: '대상 프린터',
@@ -4234,6 +4238,7 @@ export default {
       bindIpRequired: '먼저 바인드 IP를 설정해 주세요',
       accessCodeEmpty: '액세스 코드는 비워둘 수 없습니다',
       accessCodeLength: '액세스 코드는 정확히 8자여야 합니다',
+      targetCodeChangedRebind: '액세스 코드가 새 대상 프린터에 동기화되었습니다. 새 코드를 적용하려면 슬라이서에서 이 장치를 다시 추가하세요.',
       created: '가상 프린터 생성됨',
       failedToCreate: '가상 프린터 생성 실패',
       deleted: '가상 프린터 삭제됨',

+ 5 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -4405,6 +4405,10 @@ export default {
       placeholderChange: 'Digite um novo código para alterar',
       hint: 'Deve ter exatamente 8 caracteres. Usado pelos slicers para autenticação.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Herdado da impressora alvo',
+      derivedFromTargetHint: 'Usa o código de acesso da impressora alvo. A ponte encaminha a autenticação do slicer para a impressora real, então os códigos precisam corresponder — edite o código nas configurações da impressora para alterá-lo.',
+      reveal: 'Mostrar código de acesso',
+      hide: 'Ocultar código de acesso',
     },
     targetPrinter: {
       title: 'Impressora Alvo',
@@ -4477,6 +4481,7 @@ export default {
       bindIpRequired: 'Defina um IP de ligação primeiro',
       accessCodeEmpty: 'O código de acesso não pode estar vazio',
       accessCodeLength: 'O código de acesso deve ter exatamente 8 caracteres',
+      targetCodeChangedRebind: 'O código de acesso agora corresponde à nova impressora alvo. Adicione este dispositivo novamente no seu slicer para usar o novo código.',
       created: 'Impressora virtual criada',
       failedToCreate: 'Falha ao criar impressora virtual',
       deleted: 'Impressora virtual excluída',

+ 5 - 0
frontend/src/i18n/locales/tr.ts

@@ -4380,6 +4380,10 @@ export default {
       placeholderChange: 'Değiştirmek için yeni kodu girin',
       hint: 'Tam olarak 8 karakter olmalı. Dilimleyiciler tarafından kimlik doğrulama için kullanılır.',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: 'Hedef yazıcıdan devralındı',
+      derivedFromTargetHint: 'Hedef yazıcının erişim kodunu kullanır. Köprü, dilimleyicinin kimlik doğrulamasını gerçek yazıcıya iletir, bu nedenle kodlar eşleşmelidir — değiştirmek için yazıcının ayarlarında erişim kodunu düzenleyin.',
+      reveal: 'Erişim kodunu göster',
+      hide: 'Erişim kodunu gizle',
     },
     targetPrinter: {
       title: 'Hedef Yazıcı',
@@ -4466,6 +4470,7 @@ export default {
       bindIpRequired: 'Lütfen önce bir bind IP\'si ayarlayın',
       accessCodeEmpty: 'Erişim kodu boş olamaz',
       accessCodeLength: 'Erişim kodu tam olarak 8 karakter olmalı',
+      targetCodeChangedRebind: 'Erişim kodu artık yeni hedef yazıcıyla eşleşiyor. Yeni kodu almak için bu cihazı dilimleyicinizde yeniden ekleyin.',
       created: 'Sanal yazıcı oluşturuldu',
       failedToCreate: 'Sanal yazıcı oluşturulamadı',
       deleted: 'Sanal yazıcı silindi',

+ 5 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -4405,6 +4405,10 @@ export default {
       placeholderChange: '输入新代码以更改',
       hint: '必须恰好 8 个字符。切片软件使用此代码进行认证。',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: '继承自目标打印机',
+      derivedFromTargetHint: '使用目标打印机的访问码。桥接会将切片软件的认证转发到实际打印机,因此两个访问码必须匹配 — 如需更改,请在打印机设置中编辑访问码。',
+      reveal: '显示访问码',
+      hide: '隐藏访问码',
     },
     targetPrinter: {
       title: '目标打印机',
@@ -4491,6 +4495,7 @@ export default {
       bindIpRequired: '请先设置绑定 IP',
       accessCodeEmpty: '访问码不能为空',
       accessCodeLength: '访问码必须恰好 8 个字符',
+      targetCodeChangedRebind: '访问码已同步到新的目标打印机。请在切片软件中重新添加此设备以使用新的访问码。',
       created: '虚拟打印机已创建',
       failedToCreate: '创建虚拟打印机失败',
       deleted: '虚拟打印机已删除',

+ 5 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -4405,6 +4405,10 @@ export default {
       placeholderChange: '輸入新程式碼以更改',
       hint: '必須恰好 8 個字元。切片軟體使用此程式碼進行認證。',
       charCount: '({{count}}/8)',
+      inheritedFromTarget: '繼承自目標印表機',
+      derivedFromTargetHint: '使用目標印表機的存取碼。橋接會將切片軟體的認證轉發到實際印表機,因此兩個存取碼必須相符 — 如需變更,請在印表機設定中編輯存取碼。',
+      reveal: '顯示存取碼',
+      hide: '隱藏存取碼',
     },
     targetPrinter: {
       title: '目標印表機',
@@ -4491,6 +4495,7 @@ export default {
       bindIpRequired: '請先設定繫結 IP',
       accessCodeEmpty: '存取碼不能為空',
       accessCodeLength: '存取碼必須恰好 8 個字元',
+      targetCodeChangedRebind: '存取碼已同步到新的目標印表機。請在切片軟體中重新加入此裝置以使用新的存取碼。',
       created: '虛擬印表機已建立',
       failedToCreate: '建立虛擬印表機失敗',
       deleted: '虛擬印表機已刪除',

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DMeff9V8.js


Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
static/assets/index-DgecYhis.css


+ 2 - 2
static/index.html

@@ -26,8 +26,8 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-14DWwfbR.js"></script>
-    <link rel="stylesheet" crossorigin href="/assets/index-Df3XYvpK.css">
+    <script type="module" crossorigin src="/assets/index-DMeff9V8.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DgecYhis.css">
   </head>
   <body>
     <div id="root"></div>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác