Przeglądaj źródła

feat(auth): connected apps - sign in to external applications with Bambuddy

Minimal OAuth 2.0 authorization-code flow with PKCE (S256): admins register
an app with one exact callback URL (Settings > API Keys > Connected Apps);
/connect/authorize asks for consent once and returns a single-use, 60 s code
bound to app, callback and challenge; POST /api/v1/connect/token swaps it,
with the client secret, for the user's identity and permissions. Codes and
secrets stored hashed, exchanges rate-limited per client and IP, no redirect
before the callback is validated, API keys cannot authorize, refused while
auth is disabled. i18n for all 15 locales.

-----

fix(db): upgrading from 0.2.4.0 or older no longer crashes at startup

The #2974 failure-reason conversion ran before the #1378 migration that adds
print_log_entries.failure_reason, so older databases stopped with "no such
column: failure_reason". It now skips a table without the column, only runs
where a legacy label exists, and on SQLite rebuilds archive_fts first, since
archives created before that index existed trip "database disk image is
malformed" when updated.
maziggy 2 dni temu
rodzic
commit
d56b48c499
42 zmienionych plików z 2833 dodań i 6 usunięć
  1. 2 0
      CHANGELOG.md
  2. 405 0
      backend/app/api/routes/connected_apps.py
  3. 16 0
      backend/app/api/routes/users.py
  4. 52 0
      backend/app/core/database.py
  5. 5 0
      backend/app/main.py
  6. 3 0
      backend/app/models/__init__.py
  7. 27 0
      backend/app/models/auth_ephemeral.py
  8. 48 0
      backend/app/models/connected_app.py
  9. 109 0
      backend/app/schemas/connected_app.py
  10. 363 0
      backend/tests/integration/test_connected_apps.py
  11. 81 0
      backend/tests/integration/test_failure_reason_vocabulary_migration.py
  12. 45 0
      backend/tests/integration/test_users_auth_cleanup.py
  13. 4 0
      backend/tests/unit/test_route_auth_coverage.py
  14. 5 0
      frontend/src/App.tsx
  15. 97 0
      frontend/src/__tests__/components/ConnectedAppsSection.test.tsx
  16. 149 0
      frontend/src/__tests__/pages/ConnectAuthorizePage.test.tsx
  17. 43 0
      frontend/src/api/client.ts
  18. 337 0
      frontend/src/components/ConnectedAppsSection.tsx
  19. 57 0
      frontend/src/i18n/locales/de.ts
  20. 57 0
      frontend/src/i18n/locales/en.ts
  21. 57 0
      frontend/src/i18n/locales/es.ts
  22. 57 0
      frontend/src/i18n/locales/fr.ts
  23. 57 0
      frontend/src/i18n/locales/it.ts
  24. 57 0
      frontend/src/i18n/locales/ja.ts
  25. 57 0
      frontend/src/i18n/locales/ko.ts
  26. 57 0
      frontend/src/i18n/locales/nl.ts
  27. 57 0
      frontend/src/i18n/locales/pt-BR.ts
  28. 57 0
      frontend/src/i18n/locales/ru.ts
  29. 57 0
      frontend/src/i18n/locales/sv.ts
  30. 57 0
      frontend/src/i18n/locales/tr.ts
  31. 57 0
      frontend/src/i18n/locales/uk.ts
  32. 57 0
      frontend/src/i18n/locales/zh-CN.ts
  33. 57 0
      frontend/src/i18n/locales/zh-TW.ts
  34. 164 0
      frontend/src/pages/ConnectAuthorizePage.tsx
  35. 19 1
      frontend/src/pages/SettingsPage.tsx
  36. 0 1
      static/assets/PdfPreviewModal-DcCIp96X.js
  37. 0 0
      static/assets/SpreadsheetPreviewModal-DkGzwxhn.js
  38. 0 1
      static/assets/index-BCr8craX.css
  39. 1 1
      static/assets/index-VsfgUmwa.js
  40. 1 0
      static/assets/index-ldjSdDIZ.css
  41. 0 0
      static/assets/pdf-CFOqAiZi.js
  42. 2 2
      static/index.html

Plik diff jest za duży
+ 2 - 0
CHANGELOG.md


+ 405 - 0
backend/app/api/routes/connected_apps.py

@@ -0,0 +1,405 @@
+"""Connected apps: sign users in to external applications with Bambuddy.
+
+A minimal OAuth 2.0 authorization-code flow with PKCE (S256 only):
+
+1. The app sends the user's browser to the SPA page ``/connect/authorize``.
+   The page, running with the user's normal Bambuddy session, calls
+   ``GET /connect/authorize/info`` to show who is asking and, after consent,
+   ``POST /connect/authorize`` for a code. It then sends the browser back to
+   the app's registered callback URL with that code.
+2. The app's server calls ``POST /connect/token`` with the code, its client
+   secret and the PKCE verifier, and receives the user's identity and
+   permissions.
+
+What the app never gets is the user's Bambuddy login token. What a code is
+worth is deliberately small: single use, 60 seconds, bound to one app, its
+exact callback URL and the PKCE challenge, and only redeemable with the app's
+secret.
+
+Sign-in through an app only exists while Bambuddy authentication is enabled.
+With it disabled there are no users to sign in, and both authorize and token
+answer ``auth_disabled`` so the app can fall back to its own login rather
+than let everyone in.
+"""
+
+import base64
+import functools
+import hashlib
+import hmac
+import logging
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
+from fastapi.security import HTTPAuthorizationCredentials
+from sqlalchemy import delete, select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import (
+    RequirePermissionIfAuthEnabled,
+    get_current_user,
+    get_password_hash,
+    get_user_by_username,
+    is_auth_enabled,
+    security,
+    verify_password,
+)
+from backend.app.core.database import get_db
+from backend.app.core.permissions import Permission
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, EventType, TokenType
+from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
+from backend.app.models.user import User
+from backend.app.schemas.connected_app import (
+    ConnectAuthorizeInfo,
+    ConnectAuthorizeRequest,
+    ConnectAuthorizeResponse,
+    ConnectedAppCreate,
+    ConnectedAppResponse,
+    ConnectedAppSecretResponse,
+    ConnectedAppUpdate,
+    ConnectedUser,
+    ConnectTokenRequest,
+    ConnectTokenResponse,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(prefix="/connect", tags=["connected-apps"])
+
+CODE_TTL = timedelta(seconds=60)
+# Failed token exchanges tolerated per client and per IP in the rate-limit
+# window (mfa.LOCKOUT_WINDOW). A working app fails almost never; this only has
+# to stop someone guessing secrets or codes.
+MAX_FAILED_TOKEN_EXCHANGES = 20
+
+AUTH_DISABLED = "auth_disabled"
+
+
+@functools.cache
+def _dummy_secret_hash() -> str:
+    """Verified against when the client_id is unknown, so an unknown client
+    costs the same bcrypt round as a wrong secret and the two can't be told
+    apart by timing. Built on first use to keep bcrypt out of import time."""
+    return get_password_hash(secrets.token_urlsafe(32))
+
+
+def _hash_code(code: str) -> str:
+    return hashlib.sha256(code.encode()).hexdigest()
+
+
+def _s256(verifier: str) -> str:
+    digest = hashlib.sha256(verifier.encode("ascii")).digest()
+    return base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
+
+
+def _new_client_credentials() -> tuple[str, str]:
+    return f"bba_{secrets.token_hex(12)}", f"bbs_{secrets.token_urlsafe(32)}"
+
+
+def _with_secret(app: ConnectedApp, client_secret: str) -> ConnectedAppSecretResponse:
+    return ConnectedAppSecretResponse(
+        **ConnectedAppResponse.model_validate(app).model_dump(), client_secret=client_secret
+    )
+
+
+async def _get_app_or_404(db: AsyncSession, app_id: int) -> ConnectedApp:
+    app = await db.get(ConnectedApp, app_id)
+    if app is None:
+        raise HTTPException(404, "Connected app not found")
+    return app
+
+
+# --------------------------------------------------------------------------
+# Admin: register and manage apps
+# --------------------------------------------------------------------------
+
+
+@router.get("/apps", response_model=list[ConnectedAppResponse])
+async def list_connected_apps(
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    result = await db.execute(select(ConnectedApp).order_by(ConnectedApp.created_at.desc()))
+    return list(result.scalars().all())
+
+
+@router.post("/apps", response_model=ConnectedAppSecretResponse)
+async def create_connected_app(
+    data: ConnectedAppCreate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Register an app. The client secret is in this response and nowhere else."""
+    if not await is_auth_enabled(db):
+        # Nobody could sign in through it, and an admin who set one up would
+        # reasonably assume the app is now protected by Bambuddy's login.
+        raise HTTPException(400, "Connected apps require authentication to be enabled")
+    client_id, client_secret = _new_client_credentials()
+    app = ConnectedApp(
+        name=data.name.strip(),
+        client_id=client_id,
+        client_secret_hash=get_password_hash(client_secret),
+        redirect_uri=data.redirect_uri,
+        enabled=True,
+        created_by_id=current_user.id if current_user else None,
+    )
+    db.add(app)
+    await db.commit()
+    await db.refresh(app)
+    logger.info("Connected app %s '%s' registered", app.id, app.name)
+    return _with_secret(app, client_secret)
+
+
+@router.patch("/apps/{app_id}", response_model=ConnectedAppResponse)
+async def update_connected_app(
+    app_id: int,
+    data: ConnectedAppUpdate,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    app = await _get_app_or_404(db, app_id)
+    if data.name is not None:
+        app.name = data.name.strip()
+    if data.redirect_uri is not None:
+        app.redirect_uri = data.redirect_uri
+    if data.enabled is not None:
+        app.enabled = data.enabled
+    await db.commit()
+    await db.refresh(app)
+    return app
+
+
+@router.post("/apps/{app_id}/rotate-secret", response_model=ConnectedAppSecretResponse)
+async def rotate_connected_app_secret(
+    app_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    """Replace the client secret. The old one stops working immediately."""
+    app = await _get_app_or_404(db, app_id)
+    _, client_secret = _new_client_credentials()
+    app.client_secret_hash = get_password_hash(client_secret)
+    await db.commit()
+    await db.refresh(app)
+    logger.info("Connected app %s secret rotated", app.id)
+    return _with_secret(app, client_secret)
+
+
+@router.delete("/apps/{app_id}")
+async def delete_connected_app(
+    app_id: int,
+    db: AsyncSession = Depends(get_db),
+    _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
+):
+    app = await _get_app_or_404(db, app_id)
+    await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.app_id == app.id))
+    # Outstanding codes would fail anyway (their app is gone); drop them now.
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
+            AuthEphemeralToken.provider_id == app.id,
+        )
+    )
+    await db.delete(app)
+    await db.commit()
+    logger.info("Connected app %s deleted", app_id)
+    return {"message": "Connected app deleted"}
+
+
+# --------------------------------------------------------------------------
+# Authorize: called by the SPA with the signed-in user's session
+# --------------------------------------------------------------------------
+
+
+async def require_signed_in_user(
+    credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
+    db: AsyncSession = Depends(get_db),
+) -> User:
+    """The user behind a Bambuddy login token. API keys are refused.
+
+    An API key would let a script sign its owner in to an app without the
+    owner ever seeing a consent screen; only a person's own session may do
+    that. ``get_current_user`` accepts JWTs only, so a ``bb_`` key fails there.
+    """
+    if not await is_auth_enabled(db):
+        raise HTTPException(409, AUTH_DISABLED)
+    return await get_current_user(credentials)
+
+
+async def _app_for_authorize(db: AsyncSession, client_id: str, redirect_uri: str) -> ConnectedApp:
+    result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == client_id))
+    app = result.scalar_one_or_none()
+    # One message for every case: the page must not redirect anywhere unless
+    # all of these hold, and there is nothing useful to tell the user apart.
+    if app is None or not app.enabled or not hmac.compare_digest(app.redirect_uri, redirect_uri):
+        raise HTTPException(400, "Unknown app, app disabled, or callback URL does not match its registration")
+    return app
+
+
+@router.get("/authorize/info", response_model=ConnectAuthorizeInfo)
+async def authorize_info(
+    client_id: str = Query(..., max_length=64),
+    redirect_uri: str = Query(..., max_length=500),
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(require_signed_in_user),
+):
+    """What the consent screen shows. Validates the request before the page acts on it."""
+    app = await _app_for_authorize(db, client_id, redirect_uri)
+    granted = await db.execute(
+        select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
+    )
+    return ConnectAuthorizeInfo(
+        app_name=app.name, username=user.username, already_granted=granted.scalar_one_or_none() is not None
+    )
+
+
+@router.post("/authorize", response_model=ConnectAuthorizeResponse)
+async def authorize(
+    data: ConnectAuthorizeRequest,
+    db: AsyncSession = Depends(get_db),
+    user: User = Depends(require_signed_in_user),
+):
+    """Issue a code for the signed-in user and record their consent."""
+    app = await _app_for_authorize(db, data.client_id, data.redirect_uri)
+    now = datetime.now(timezone.utc)
+
+    # Keep the table small: codes live for a minute, so anything expired is junk.
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
+            AuthEphemeralToken.expires_at < now,
+        )
+    )
+
+    granted = await db.execute(
+        select(ConnectedAppGrant.id).where(ConnectedAppGrant.app_id == app.id, ConnectedAppGrant.user_id == user.id)
+    )
+    if granted.scalar_one_or_none() is None:
+        db.add(ConnectedAppGrant(app_id=app.id, user_id=user.id))
+
+    code = secrets.token_urlsafe(32)
+    db.add(
+        AuthEphemeralToken.new_connect_code(
+            code_hash=_hash_code(code),
+            username=user.username,
+            app_id=app.id,
+            code_challenge=data.code_challenge,
+            expires_at=now + CODE_TTL,
+        )
+    )
+    try:
+        await db.commit()
+    except IntegrityError:
+        # Two tabs consenting at once both inserted the grant; the other one won.
+        await db.rollback()
+        db.add(
+            AuthEphemeralToken.new_connect_code(
+                code_hash=_hash_code(code),
+                username=user.username,
+                app_id=app.id,
+                code_challenge=data.code_challenge,
+                expires_at=now + CODE_TTL,
+            )
+        )
+        await db.commit()
+
+    logger.info("Connected app %s: code issued for user %s", app.id, user.id)
+    return ConnectAuthorizeResponse(code=code, redirect_uri=app.redirect_uri)
+
+
+# --------------------------------------------------------------------------
+# Token: called by the app's server, authenticated by its client secret
+# --------------------------------------------------------------------------
+
+
+def _token_error(status_code: int, error: str) -> HTTPException:
+    return HTTPException(status_code, {"error": error})
+
+
+@router.post("/token", response_model=ConnectTokenResponse)
+async def exchange_code(
+    request: Request,
+    data: ConnectTokenRequest,
+    db: AsyncSession = Depends(get_db),
+):
+    """Swap a code for the user's identity and permissions.
+
+    Every failure after the rate-limit check counts against both the client and
+    the caller's IP. The response says only ``invalid_client`` (who is asking
+    is wrong) or ``invalid_grant`` (the code is), never which check failed.
+    """
+    from backend.app.api.routes.auth import _get_client_ip
+    from backend.app.api.routes.mfa import check_rate_limit, clear_failed_attempts, record_failed_attempt
+
+    client_ip = _get_client_ip(request)
+    await check_rate_limit(
+        db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
+    )
+    await check_rate_limit(
+        db, client_ip, event_type=EventType.CONNECT_TOKEN_IP, max_attempts=MAX_FAILED_TOKEN_EXCHANGES
+    )
+
+    async def fail(status_code: int, error: str, reason: str) -> HTTPException:
+        await record_failed_attempt(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
+        await record_failed_attempt(db, client_ip, event_type=EventType.CONNECT_TOKEN_IP)
+        logger.warning("Connected app token exchange refused for client %s: %s", data.client_id, reason)
+        return _token_error(status_code, error)
+
+    if not await is_auth_enabled(db):
+        raise _token_error(400, AUTH_DISABLED)
+
+    result = await db.execute(select(ConnectedApp).where(ConnectedApp.client_id == data.client_id))
+    app = result.scalar_one_or_none()
+    secret_ok = verify_password(data.client_secret, app.client_secret_hash if app else _dummy_secret_hash())
+    if app is None or not secret_ok:
+        raise await fail(401, "invalid_client", "unknown client or wrong secret")
+    if not app.enabled:
+        raise await fail(401, "invalid_client", "app disabled")
+
+    # Consume first, then check: DELETE ... RETURNING makes the code single-use
+    # even under concurrent exchanges, and a code that fails a later check is
+    # spent all the same.
+    now = datetime.now(timezone.utc)
+    consumed = await db.execute(
+        delete(AuthEphemeralToken)
+        .where(
+            AuthEphemeralToken.token == _hash_code(data.code),
+            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
+            AuthEphemeralToken.provider_id == app.id,
+            AuthEphemeralToken.expires_at > now,
+        )
+        .returning(AuthEphemeralToken.username, AuthEphemeralToken.nonce)
+    )
+    row = consumed.one_or_none()
+    await db.commit()
+    if row is None:
+        raise await fail(400, "invalid_grant", "unknown, expired, reused or foreign code")
+    username, code_challenge = row
+
+    if not hmac.compare_digest(app.redirect_uri, data.redirect_uri):
+        raise await fail(400, "invalid_grant", "callback URL mismatch")
+    if not code_challenge or not hmac.compare_digest(_s256(data.code_verifier), code_challenge):
+        raise await fail(400, "invalid_grant", "PKCE verifier mismatch")
+
+    user = await get_user_by_username(db, username)
+    if user is None or not user.is_active:
+        raise await fail(400, "invalid_grant", "user gone or disabled")
+
+    app.last_used_at = now.replace(tzinfo=None)
+    await clear_failed_attempts(db, data.client_id, event_type=EventType.CONNECT_TOKEN_CLIENT)
+    await db.commit()
+
+    logger.info("Connected app %s: user %s signed in", app.id, user.id)
+    return ConnectTokenResponse(
+        user=ConnectedUser(
+            id=user.id,
+            username=user.username,
+            email=user.email,
+            is_admin=user.is_admin,
+            groups=sorted(g.name for g in user.groups),
+            permissions=sorted(user.get_permissions()),
+        ),
+        issued_at=now,
+    )

+ 16 - 0
backend/app/api/routes/users.py

@@ -25,6 +25,8 @@ from backend.app.core.database import get_db
 from backend.app.core.permissions import Permission
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
+from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.group import Group
 from backend.app.models.library import LibraryFile
 from backend.app.models.long_lived_token import LongLivedToken
@@ -499,6 +501,20 @@ async def delete_user(
     await db.execute(delete(UserOTPCode).where(UserOTPCode.user_id == user_id))
     await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
 
+    # Connected apps, same SQLite/FK pattern. A leftover consent row would
+    # skip the consent screen for whoever is next given this user id, and a
+    # code issued in the last minute is keyed by username, not id.
+    from sqlalchemy import update as _update
+
+    await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
+    await db.execute(
+        delete(AuthEphemeralToken).where(
+            AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
+            func.lower(AuthEphemeralToken.username) == user.username.lower(),
+        )
+    )
+    await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
+
     await db.delete(user)
     await db.commit()
 

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

@@ -293,6 +293,7 @@ async def init_db():
         auth_ephemeral,
         bug_report,
         color_catalog,
+        connected_app,
         external_link,
         filament,
         filament_sku_settings,
@@ -1702,6 +1703,31 @@ _LEGACY_FAILURE_REASON_LABELS: dict[str, str] = {
 }
 
 
+async def _table_has_column(conn, table: str, column: str) -> bool:
+    """Whether ``table`` has ``column``, on either dialect.
+
+    ``table`` is interpolated into the SQLite PRAGMA (it cannot be bound), so
+    callers pass literals only.
+    """
+    from sqlalchemy import text
+
+    if is_sqlite():
+        result = await conn.execute(text(f"PRAGMA table_info({table})"))
+        return any(row[1] == column for row in result)
+    result = await conn.execute(
+        text("SELECT 1 FROM information_schema.columns WHERE table_name = :table AND column_name = :col"),
+        {"table": table, "col": column},
+    )
+    return result.first() is not None
+
+
+async def _sqlite_table_exists(conn, name: str) -> bool:
+    from sqlalchemy import text
+
+    result = await conn.execute(text("SELECT 1 FROM sqlite_master WHERE name = :name"), {"name": name})
+    return result.first() is not None
+
+
 async def _migrate_failure_reason_vocabulary(conn):
     """Fold historical failure-reason labels onto the canonical keys (#2974).
 
@@ -1734,6 +1760,8 @@ async def _migrate_failure_reason_vocabulary(conn):
         if label != key:
             by_key[key].append(label)
 
+    all_labels = [label for labels in by_key.values() for label in labels]
+
     total = 0
     async with conn.begin_nested():
         # nosec B608 — the only interpolated fragment is `table`, which the loop
@@ -1741,6 +1769,30 @@ async def _migrate_failure_reason_vocabulary(conn):
         # Both the key and the label list are bound parameters. A table name
         # cannot be expressed as one, which is why it is interpolated at all.
         for table in ("print_archives", "print_log_entries"):
+            # A database older than #1378 has no print_log_entries.failure_reason
+            # yet (a later ALTER in run_migrations adds it). Such a table cannot
+            # hold a legacy label, and querying it crashed startup with "no such
+            # column: failure_reason".
+            if not await _table_has_column(conn, table, "failure_reason"):
+                continue
+            has_work = (
+                await conn.execute(
+                    text(
+                        f"SELECT 1 FROM {table} WHERE failure_reason IN :labels LIMIT 1"  # noqa: S608  # nosec B608
+                    ).bindparams(bindparam("labels", expanding=True)),
+                    {"labels": all_labels},
+                )
+            ).first() is not None
+            if not has_work:
+                continue
+            if table == "print_archives" and is_sqlite() and await _sqlite_table_exists(conn, "archive_fts"):
+                # Same trap as the plate_id backfill further down: archives
+                # created before the external-content FTS index existed were
+                # never indexed, and the AFTER UPDATE trigger's FTS 'delete' on
+                # such a row fails with "database disk image is malformed".
+                # Rebuild first so every row is present. Only when there is
+                # work, since a rebuild re-reads every archive.
+                await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
             for key, labels in by_key.items():
                 result = await conn.execute(
                     text(

+ 5 - 0
backend/app/main.py

@@ -28,6 +28,7 @@ from backend.app.api.routes import (
     camera,
     camwall,
     cloud,
+    connected_apps,
     discovery,
     external_links,
     filaments,
@@ -9472,6 +9473,9 @@ PUBLIC_API_ROUTES = {
     "/api/v1/auth/oidc/providers",  # Public list of enabled providers
     "/api/v1/auth/oidc/callback",  # Redirect target from OIDC provider
     "/api/v1/auth/oidc/exchange",  # Exchange short-lived OIDC token for JWT
+    # Connected apps: the app's server swaps a code for the user's identity,
+    # authenticated by its client secret rather than a login token.
+    "/api/v1/connect/token",
     # Version check for updates (no sensitive data)
     "/api/v1/updates/version",
     # Metrics endpoint handles its own prometheus_token authentication
@@ -9939,6 +9943,7 @@ app.include_router(slicer_presets.router, prefix=app_settings.api_prefix)
 app.include_router(archive_purge.router, prefix=app_settings.api_prefix)
 app.include_router(makerworld.router, prefix=app_settings.api_prefix)
 app.include_router(api_keys.router, prefix=app_settings.api_prefix)
+app.include_router(connected_apps.router, prefix=app_settings.api_prefix)
 app.include_router(webhook.router, prefix=app_settings.api_prefix)
 app.include_router(ams_history.router, prefix=app_settings.api_prefix)
 app.include_router(printer_sensor_history.router, prefix=app_settings.api_prefix)

+ 3 - 0
backend/app/models/__init__.py

@@ -4,6 +4,7 @@ from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
 from backend.app.models.color_catalog import ColorCatalogEntry
+from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
 from backend.app.models.filament import Filament
 from backend.app.models.github_backup import GitHubBackupConfig, GitHubBackupLog
 from backend.app.models.group import Group, user_groups
@@ -98,4 +99,6 @@ __all__ = [
     "AuthEphemeralToken",
     "AuthRateLimitEvent",
     "LongLivedToken",
+    "ConnectedApp",
+    "ConnectedAppGrant",
 ]

+ 27 - 0
backend/app/models/auth_ephemeral.py

@@ -42,6 +42,7 @@ class TokenType(str, Enum):
     PASSWORD_RESET = "password_reset"
     EMAIL_OTP_SETUP = "email_otp_setup"
     SLICER_DOWNLOAD = "slicer_download"
+    CONNECT_CODE = "connect_code"
 
 
 class EventType(str, Enum):
@@ -57,6 +58,8 @@ class EventType(str, Enum):
     LOGIN_IP = "login_ip"
     PASSWORD_RESET_SEND = "password_reset_send"
     PASSWORD_RESET_IP = "password_reset_ip"
+    CONNECT_TOKEN_CLIENT = "connect_client"
+    CONNECT_TOKEN_IP = "connect_ip"
 
 
 class AuthEphemeralToken(Base):
@@ -183,6 +186,30 @@ class AuthEphemeralToken(Base):
             expires_at=expires_at,
         )
 
+    @classmethod
+    def new_connect_code(
+        cls,
+        code_hash: str,
+        username: str,
+        app_id: int,
+        code_challenge: str,
+        expires_at: datetime,
+    ) -> AuthEphemeralToken:
+        """Create a connected-app authorization code.
+
+        Only the SHA-256 of the code is stored (``token``), so a database read
+        does not yield a usable code. Field reuse: ``provider_id`` holds the
+        connected app's id and ``nonce`` the PKCE S256 challenge.
+        """
+        return cls(
+            token=code_hash,
+            token_type=TokenType.CONNECT_CODE,
+            username=username,
+            provider_id=app_id,
+            nonce=code_challenge,
+            expires_at=expires_at,
+        )
+
 
 class AuthRateLimitEvent(Base):
     """Timestamped events used for sliding-window rate limiting."""

+ 48 - 0
backend/app/models/connected_app.py

@@ -0,0 +1,48 @@
+"""Connected apps: external applications that sign users in with Bambuddy.
+
+A connected app is registered by an admin with one exact callback URL. It
+signs a user in through a minimal OAuth 2.0 authorization-code flow with PKCE
+(see ``api/routes/connected_apps.py``): Bambuddy hands the app a single-use,
+60-second code, and the app's server swaps it for the user's identity and
+permissions. The app never sees the user's Bambuddy login token.
+"""
+
+from datetime import datetime
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class ConnectedApp(Base):
+    __tablename__ = "connected_apps"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    name: Mapped[str] = mapped_column(String(100))
+    # Public identifier the app sends on every request.
+    client_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    # bcrypt hash; the secret itself is shown once, at creation or rotation.
+    client_secret_hash: Mapped[str] = mapped_column(String(255))
+    # Codes are only ever delivered here. Compared exactly, never by prefix.
+    redirect_uri: Mapped[str] = mapped_column(String(500))
+    enabled: Mapped[bool] = mapped_column(Boolean, default=True)
+    created_by_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+    last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+
+
+class ConnectedAppGrant(Base):
+    """A user's consent for one app, so the consent screen is shown only once.
+
+    Deleting the app or the user removes the grant, and the next sign-in asks
+    again.
+    """
+
+    __tablename__ = "connected_app_grants"
+    __table_args__ = (Index("uq_connected_app_grants_app_user", "app_id", "user_id", unique=True),)
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True)
+    app_id: Mapped[int] = mapped_column(ForeignKey("connected_apps.id", ondelete="CASCADE"), index=True)
+    user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
+    granted_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 109 - 0
backend/app/schemas/connected_app.py

@@ -0,0 +1,109 @@
+from datetime import datetime
+from typing import Literal
+from urllib.parse import urlsplit
+
+from pydantic import BaseModel, Field, field_validator
+
+from backend.app.schemas.print_queue import UTCDatetime
+
+# RFC 7636: 43-128 chars from the unreserved set. The challenge is the
+# base64url SHA-256 of the verifier, so it is always exactly 43 chars.
+_PKCE_VERIFIER_PATTERN = r"^[A-Za-z0-9\-._~]{43,128}$"
+_PKCE_CHALLENGE_PATTERN = r"^[A-Za-z0-9\-_]{43}$"
+
+
+def _validate_redirect_uri(value: str) -> str:
+    """Accept only an absolute http(s) URL without a fragment.
+
+    Plain http is allowed: connected apps typically run on the same LAN as
+    Bambuddy, often without TLS. What matters is that codes can only ever be
+    delivered to the one URL an admin registered, and that is enforced by an
+    exact match at authorize and token time.
+    """
+    value = value.strip()
+    parts = urlsplit(value)
+    if parts.scheme not in ("http", "https") or not parts.netloc:
+        raise ValueError("Callback URL must be an absolute http:// or https:// URL")
+    if parts.fragment:
+        raise ValueError("Callback URL must not contain a #fragment")
+    if parts.username or parts.password:
+        raise ValueError("Callback URL must not contain credentials")
+    return value
+
+
+class ConnectedAppCreate(BaseModel):
+    name: str = Field(min_length=1, max_length=100)
+    redirect_uri: str = Field(min_length=1, max_length=500)
+
+    _check_redirect = field_validator("redirect_uri")(_validate_redirect_uri)
+
+
+class ConnectedAppUpdate(BaseModel):
+    name: str | None = Field(default=None, min_length=1, max_length=100)
+    redirect_uri: str | None = Field(default=None, min_length=1, max_length=500)
+    enabled: bool | None = None
+
+    @field_validator("redirect_uri")
+    @classmethod
+    def _check_redirect(cls, value: str | None) -> str | None:
+        return None if value is None else _validate_redirect_uri(value)
+
+
+class ConnectedAppResponse(BaseModel):
+    id: int
+    name: str
+    client_id: str
+    redirect_uri: str
+    enabled: bool
+    created_at: UTCDatetime
+    last_used_at: UTCDatetime | None = None
+
+    class Config:
+        from_attributes = True
+
+
+class ConnectedAppSecretResponse(ConnectedAppResponse):
+    """Returned by create and rotate only: the one time the secret is shown."""
+
+    client_secret: str
+
+
+class ConnectAuthorizeInfo(BaseModel):
+    app_name: str
+    username: str
+    already_granted: bool
+
+
+class ConnectAuthorizeRequest(BaseModel):
+    client_id: str = Field(min_length=1, max_length=64)
+    redirect_uri: str = Field(min_length=1, max_length=500)
+    code_challenge: str = Field(pattern=_PKCE_CHALLENGE_PATTERN)
+    code_challenge_method: Literal["S256"]
+
+
+class ConnectAuthorizeResponse(BaseModel):
+    code: str
+    redirect_uri: str
+
+
+class ConnectTokenRequest(BaseModel):
+    grant_type: Literal["authorization_code"]
+    code: str = Field(min_length=1, max_length=128)
+    redirect_uri: str = Field(min_length=1, max_length=500)
+    client_id: str = Field(min_length=1, max_length=64)
+    client_secret: str = Field(min_length=1, max_length=128)
+    code_verifier: str = Field(pattern=_PKCE_VERIFIER_PATTERN)
+
+
+class ConnectedUser(BaseModel):
+    id: int
+    username: str
+    email: str | None = None
+    is_admin: bool
+    groups: list[str]
+    permissions: list[str]
+
+
+class ConnectTokenResponse(BaseModel):
+    user: ConnectedUser
+    issued_at: datetime

+ 363 - 0
backend/tests/integration/test_connected_apps.py

@@ -0,0 +1,363 @@
+"""Connected apps: sign-in to external applications with a Bambuddy account.
+
+These lock down what a code is worth: single use, 60 seconds, one app, its
+exact callback URL, the PKCE challenge, and only with the app's secret. And
+that none of it works while Bambuddy authentication is disabled.
+"""
+
+import base64
+import hashlib
+import secrets
+from datetime import datetime, timedelta, timezone
+
+import pytest
+from httpx import AsyncClient
+
+CALLBACK = "http://orders.local:8090/auth/callback"
+
+
+def _pkce() -> tuple[str, str]:
+    verifier = secrets.token_urlsafe(48)
+    challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode()
+    return verifier, challenge
+
+
+def _auth(token: str) -> dict:
+    return {"Authorization": f"Bearer {token}"}
+
+
+@pytest.fixture
+async def admin_token(async_client: AsyncClient) -> str:
+    await async_client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": "connectadmin", "admin_password": "AdminPass1!"},
+    )
+    login = await async_client.post("/api/v1/auth/login", json={"username": "connectadmin", "password": "AdminPass1!"})
+    return login.json()["access_token"]
+
+
+@pytest.fixture
+async def operator(async_client: AsyncClient, admin_token: str) -> dict:
+    groups = (await async_client.get("/api/v1/groups/", headers=_auth(admin_token))).json()
+    operators = next(g for g in groups if g["name"] == "Operators")
+    user = (
+        await async_client.post(
+            "/api/v1/users/",
+            headers=_auth(admin_token),
+            json={"username": "shopworker", "password": "Operatorpass1!", "group_ids": [operators["id"]]},
+        )
+    ).json()
+    login = await async_client.post("/api/v1/auth/login", json={"username": "shopworker", "password": "Operatorpass1!"})
+    return {"id": user["id"], "token": login.json()["access_token"]}
+
+
+async def _register(async_client: AsyncClient, admin_token: str, **overrides) -> dict:
+    payload = {"name": "Bambuddy Orders", "redirect_uri": CALLBACK, **overrides}
+    response = await async_client.post("/api/v1/connect/apps", headers=_auth(admin_token), json=payload)
+    assert response.status_code == 200, response.text
+    return response.json()
+
+
+async def _authorize(async_client: AsyncClient, user_token: str, app: dict, challenge: str, **overrides):
+    payload = {
+        "client_id": app["client_id"],
+        "redirect_uri": app["redirect_uri"],
+        "code_challenge": challenge,
+        "code_challenge_method": "S256",
+        **overrides,
+    }
+    return await async_client.post("/api/v1/connect/authorize", headers=_auth(user_token), json=payload)
+
+
+async def _code(async_client: AsyncClient, user_token: str, app: dict) -> tuple[str, str]:
+    verifier, challenge = _pkce()
+    response = await _authorize(async_client, user_token, app, challenge)
+    assert response.status_code == 200, response.text
+    return response.json()["code"], verifier
+
+
+async def _exchange(async_client: AsyncClient, app: dict, code: str, verifier: str, **overrides):
+    payload = {
+        "grant_type": "authorization_code",
+        "code": code,
+        "redirect_uri": app["redirect_uri"],
+        "client_id": app["client_id"],
+        "client_secret": app["client_secret"],
+        "code_verifier": verifier,
+        **overrides,
+    }
+    # No Authorization header: the app authenticates with its secret alone.
+    return await async_client.post("/api/v1/connect/token", json=payload)
+
+
+class TestRegistration:
+    async def test_secret_is_shown_once(self, async_client, admin_token):
+        app = await _register(async_client, admin_token)
+        assert app["client_id"].startswith("bba_")
+        assert app["client_secret"].startswith("bbs_")
+        listed = (await async_client.get("/api/v1/connect/apps", headers=_auth(admin_token))).json()
+        assert [a["client_id"] for a in listed] == [app["client_id"]]
+        assert "client_secret" not in listed[0]
+
+    async def test_refused_while_authentication_is_disabled(self, async_client):
+        response = await async_client.post("/api/v1/connect/apps", json={"name": "Orders", "redirect_uri": CALLBACK})
+        assert response.status_code == 400
+
+    async def test_non_admin_cannot_register(self, async_client, operator):
+        response = await async_client.post(
+            "/api/v1/connect/apps",
+            headers=_auth(operator["token"]),
+            json={"name": "Orders", "redirect_uri": CALLBACK},
+        )
+        assert response.status_code == 403
+
+    @pytest.mark.parametrize(
+        "redirect_uri",
+        [
+            "javascript:alert(1)",
+            "/relative/callback",
+            "http://orders.local/cb#frag",
+            "http://user:pw@orders.local/cb",
+            "ftp://orders.local/cb",
+        ],
+    )
+    async def test_callback_must_be_a_plain_absolute_http_url(self, async_client, admin_token, redirect_uri):
+        response = await async_client.post(
+            "/api/v1/connect/apps",
+            headers=_auth(admin_token),
+            json={"name": "Orders", "redirect_uri": redirect_uri},
+        )
+        assert response.status_code == 422
+
+
+class TestSignIn:
+    async def test_full_flow_returns_identity_and_permissions(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+
+        info = await async_client.get(
+            "/api/v1/connect/authorize/info",
+            headers=_auth(operator["token"]),
+            params={"client_id": app["client_id"], "redirect_uri": CALLBACK},
+        )
+        assert info.status_code == 200
+        assert info.json() == {"app_name": "Bambuddy Orders", "username": "shopworker", "already_granted": False}
+
+        code, verifier = await _code(async_client, operator["token"], app)
+        response = await _exchange(async_client, app, code, verifier)
+        assert response.status_code == 200, response.text
+        user = response.json()["user"]
+        assert user["id"] == operator["id"]
+        assert user["username"] == "shopworker"
+        assert user["is_admin"] is False
+        assert user["groups"] == ["Operators"]
+        assert "queue:create" in user["permissions"]
+
+        info_after = await async_client.get(
+            "/api/v1/connect/authorize/info",
+            headers=_auth(operator["token"]),
+            params={"client_id": app["client_id"], "redirect_uri": CALLBACK},
+        )
+        assert info_after.json()["already_granted"] is True
+
+    async def test_authorize_needs_a_login_not_an_api_key(self, async_client, admin_token):
+        app = await _register(async_client, admin_token)
+        key = (
+            await async_client.post("/api/v1/api-keys/", headers=_auth(admin_token), json={"name": "script"})
+        ).json()["key"]
+        _, challenge = _pkce()
+        for headers in ({"X-API-Key": key}, _auth(key)):
+            response = await async_client.post(
+                "/api/v1/connect/authorize",
+                headers=headers,
+                json={
+                    "client_id": app["client_id"],
+                    "redirect_uri": CALLBACK,
+                    "code_challenge": challenge,
+                    "code_challenge_method": "S256",
+                },
+            )
+            assert response.status_code == 401
+
+    async def test_callback_must_match_exactly_at_authorize(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        _, challenge = _pkce()
+        for wrong in (CALLBACK + "/", CALLBACK + "?x=1", "http://evil.example/auth/callback"):
+            response = await _authorize(async_client, operator["token"], app, challenge, redirect_uri=wrong)
+            assert response.status_code == 400
+
+    async def test_only_s256_is_accepted(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        verifier, _ = _pkce()
+        response = await _authorize(async_client, operator["token"], app, verifier[:43], code_challenge_method="plain")
+        assert response.status_code == 422
+
+
+class TestCodeIsWorthLittle:
+    async def test_code_is_single_use(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 200
+        replay = await _exchange(async_client, app, code, verifier)
+        assert replay.status_code == 400
+        assert replay.json()["detail"] == {"error": "invalid_grant"}
+
+    async def test_expired_code_is_refused(self, async_client, admin_token, operator, db_session):
+        from sqlalchemy import update
+
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        await db_session.execute(
+            update(AuthEphemeralToken)
+            .where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
+            .values(expires_at=datetime.now(timezone.utc) - timedelta(seconds=1))
+        )
+        await db_session.commit()
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 400
+
+    async def test_code_is_not_stored_in_plain(self, async_client, admin_token, operator, db_session):
+        from sqlalchemy import select
+
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+
+        app = await _register(async_client, admin_token)
+        code, _ = await _code(async_client, operator["token"], app)
+        stored = (
+            await db_session.execute(
+                select(AuthEphemeralToken.token).where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
+            )
+        ).scalar_one()
+        assert stored != code
+
+    async def test_wrong_verifier_spends_the_code(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        other_verifier, _ = _pkce()
+        assert (await _exchange(async_client, app, code, other_verifier)).status_code == 400
+        # A failed attempt burns the code, so the right verifier can't be tried next.
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 400
+
+    async def test_wrong_callback_at_exchange_is_refused(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        response = await _exchange(async_client, app, code, verifier, redirect_uri=CALLBACK + "x")
+        assert response.status_code == 400
+
+    async def test_wrong_secret_is_invalid_client(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        response = await _exchange(async_client, app, code, verifier, client_secret="bbs_wrong")
+        assert response.status_code == 401
+        assert response.json()["detail"] == {"error": "invalid_client"}
+        # The code survives a caller that can't prove who it is.
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 200
+
+    async def test_unknown_client_is_invalid_client(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        response = await _exchange(async_client, app, code, verifier, client_id="bba_unknown")
+        assert response.status_code == 401
+
+    async def test_one_apps_code_is_useless_to_another(self, async_client, admin_token, operator):
+        app_a = await _register(async_client, admin_token, name="A")
+        app_b = await _register(async_client, admin_token, name="B")
+        code, verifier = await _code(async_client, operator["token"], app_a)
+        assert (await _exchange(async_client, app_b, code, verifier)).status_code == 400
+
+    async def test_user_disabled_after_consent_is_refused(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        await async_client.patch(
+            f"/api/v1/users/{operator['id']}", headers=_auth(admin_token), json={"is_active": False}
+        )
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 400
+
+
+class TestAppLifecycle:
+    async def test_disabled_app_can_neither_authorize_nor_exchange(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        await async_client.patch(
+            f"/api/v1/connect/apps/{app['id']}", headers=_auth(admin_token), json={"enabled": False}
+        )
+        _, challenge = _pkce()
+        assert (await _authorize(async_client, operator["token"], app, challenge)).status_code == 400
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 401
+
+    async def test_rotated_secret_replaces_the_old_one(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        rotated = (
+            await async_client.post(f"/api/v1/connect/apps/{app['id']}/rotate-secret", headers=_auth(admin_token))
+        ).json()
+        assert rotated["client_secret"] != app["client_secret"]
+        code, verifier = await _code(async_client, operator["token"], app)
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 401
+        assert (
+            await _exchange(async_client, app, code, verifier, client_secret=rotated["client_secret"])
+        ).status_code == 200
+
+    async def test_changed_callback_invalidates_codes_for_the_old_one(self, async_client, admin_token, operator):
+        app = await _register(async_client, admin_token)
+        code, verifier = await _code(async_client, operator["token"], app)
+        await async_client.patch(
+            f"/api/v1/connect/apps/{app['id']}",
+            headers=_auth(admin_token),
+            json={"redirect_uri": "http://orders.local:9000/cb"},
+        )
+        assert (await _exchange(async_client, app, code, verifier)).status_code == 400
+
+    async def test_deleting_the_app_removes_grants_and_codes(self, async_client, admin_token, operator, db_session):
+        from sqlalchemy import func, select
+
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+        from backend.app.models.connected_app import ConnectedAppGrant
+
+        app = await _register(async_client, admin_token)
+        await _code(async_client, operator["token"], app)
+        response = await async_client.delete(f"/api/v1/connect/apps/{app['id']}", headers=_auth(admin_token))
+        assert response.status_code == 200
+        grants = await db_session.execute(select(func.count()).select_from(ConnectedAppGrant))
+        codes = await db_session.execute(
+            select(func.count())
+            .select_from(AuthEphemeralToken)
+            .where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
+        )
+        assert grants.scalar_one() == 0
+        assert codes.scalar_one() == 0
+
+
+class TestAuthenticationDisabled:
+    async def test_authorize_and_token_say_auth_disabled(self, async_client):
+        info = await async_client.get(
+            "/api/v1/connect/authorize/info", params={"client_id": "bba_x", "redirect_uri": CALLBACK}
+        )
+        assert info.status_code == 409
+        assert info.json()["detail"] == "auth_disabled"
+        verifier, _ = _pkce()
+        token = await async_client.post(
+            "/api/v1/connect/token",
+            json={
+                "grant_type": "authorization_code",
+                "code": "x",
+                "redirect_uri": CALLBACK,
+                "client_id": "bba_x",
+                "client_secret": "bbs_x",
+                "code_verifier": verifier,
+            },
+        )
+        assert token.status_code == 400
+        assert token.json()["detail"] == {"error": "auth_disabled"}
+
+
+class TestRateLimit:
+    async def test_repeated_failures_lock_the_client_out(self, async_client, admin_token, operator):
+        from backend.app.api.routes.connected_apps import MAX_FAILED_TOKEN_EXCHANGES
+
+        app = await _register(async_client, admin_token)
+        verifier, _ = _pkce()
+        for _ in range(MAX_FAILED_TOKEN_EXCHANGES):
+            response = await _exchange(async_client, app, "not-a-code", verifier)
+            assert response.status_code == 400
+        code, good_verifier = await _code(async_client, operator["token"], app)
+        assert (await _exchange(async_client, app, code, good_verifier)).status_code == 429

+ 81 - 0
backend/tests/integration/test_failure_reason_vocabulary_migration.py

@@ -168,3 +168,84 @@ async def test_running_twice_changes_nothing(db_session: AsyncSession, model) ->
     first = await _read(db_session, model, ids)
     await _run(db_session)
     assert await _read(db_session, model, ids) == first == ["layerShift", "Custom legacy reason"]
+
+
+# ---------------------------------------------------------------------------
+# Position within run_migrations
+# ---------------------------------------------------------------------------
+
+
+async def test_upgrade_from_before_print_log_failure_reason_boots(tmp_path) -> None:
+    """A database older than #1378 has no print_log_entries.failure_reason.
+
+    The conversion used to run at the top of run_migrations, before the ALTER
+    that adds that column, so upgrading such a database crashed startup with
+    "no such column: failure_reason". Run the real migration sequence over a
+    database shaped like that and require it to finish, add the column, and
+    still convert the labels that were already there.
+    """
+    from sqlalchemy import text
+    from sqlalchemy.ext.asyncio import create_async_engine
+
+    from backend.app.core.database import Base, run_migrations
+
+    # Register every model on Base.metadata, as init_db does before create_all;
+    # otherwise tables the migrations ALTER would be missing.
+    from backend.app.main import app  # noqa: F401
+
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'old.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await conn.execute(text("ALTER TABLE print_log_entries DROP COLUMN failure_reason"))
+        async with AsyncSession(engine) as session:
+            session.add(PrintArchive(status="failed", failure_reason="Layer shift", **_REQUIRED["PrintArchive"]))
+            await session.commit()
+
+        async with engine.begin() as conn:
+            await run_migrations(conn)
+
+        async with engine.connect() as conn:
+            columns = {row[1] for row in (await conn.execute(text("PRAGMA table_info(print_log_entries)"))).all()}
+            reason = (await conn.execute(text("SELECT failure_reason FROM print_archives"))).scalar_one()
+        assert "failure_reason" in columns
+        assert reason == "layerShift"
+    finally:
+        await engine.dispose()
+
+
+async def test_archive_missing_from_the_fts_index_is_converted(tmp_path) -> None:
+    """Archives created before archive_fts existed were never indexed.
+
+    Updating such a row fires the FTS 'delete' trigger for a row the index
+    doesn't hold, which SQLite reports as "database disk image is malformed" --
+    fatal at startup. The conversion must rebuild the index first.
+    """
+    from sqlalchemy import text
+    from sqlalchemy.ext.asyncio import create_async_engine
+
+    from backend.app.core.database import Base, run_migrations
+
+    # Register every model on Base.metadata, as init_db does before create_all.
+    from backend.app.main import app  # noqa: F401
+
+    engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'fts.db'}")
+    try:
+        async with engine.begin() as conn:
+            await conn.run_sync(Base.metadata.create_all)
+            await run_migrations(conn)  # creates archive_fts and its triggers
+        async with AsyncSession(engine) as session:
+            session.add(PrintArchive(status="failed", failure_reason="Layer shift", **_REQUIRED["PrintArchive"]))
+            await session.commit()
+        async with engine.begin() as conn:
+            # Empty the index: the archive is now what a pre-FTS row looks like.
+            await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('delete-all')"))
+
+        async with engine.begin() as conn:
+            await _migrate_failure_reason_vocabulary(conn)
+
+        async with engine.connect() as conn:
+            reason = (await conn.execute(text("SELECT failure_reason FROM print_archives"))).scalar_one()
+        assert reason == "layerShift"
+    finally:
+        await engine.dispose()

+ 45 - 0
backend/tests/integration/test_users_auth_cleanup.py

@@ -178,6 +178,51 @@ class TestDeleteUserCleansAuthRows:
             "LongLivedToken orphan — camera-stream secret still in DB after user delete"
         )
 
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_delete_user_removes_connected_app_consent_and_codes(
+        self,
+        async_client: AsyncClient,
+        db_session: AsyncSession,
+        auth_token: str,
+    ):
+        """A leftover consent row would skip the consent screen for the next
+        user given the same id; a live code is keyed by username."""
+        from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
+        from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
+
+        user_id = await self._create_user(async_client, auth_token, "appclean")
+        app = ConnectedApp(
+            name="Orders", client_id="bba_cleanup", client_secret_hash="x", redirect_uri="http://o.local/cb"
+        )
+        db_session.add(app)
+        await db_session.flush()
+        db_session.add(ConnectedAppGrant(app_id=app.id, user_id=user_id))
+        db_session.add(
+            AuthEphemeralToken.new_connect_code(
+                code_hash="h" * 64,
+                username="AppClean",
+                app_id=app.id,
+                code_challenge="c" * 43,
+                expires_at=datetime.now(timezone.utc) + timedelta(seconds=60),
+            )
+        )
+        await db_session.commit()
+
+        resp = await async_client.delete(
+            f"/api/v1/users/{user_id}",
+            headers={"Authorization": f"Bearer {auth_token}"},
+        )
+        assert resp.status_code == 204
+
+        await db_session.commit()
+        grants = await db_session.execute(select(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
+        assert grants.scalar_one_or_none() is None
+        codes = await db_session.execute(
+            select(AuthEphemeralToken).where(AuthEphemeralToken.token_type == TokenType.CONNECT_CODE)
+        )
+        assert codes.scalar_one_or_none() is None
+
     @pytest.mark.asyncio
     @pytest.mark.integration
     async def test_delete_user_removes_user_otp_codes(

+ 4 - 0
backend/tests/unit/test_route_auth_coverage.py

@@ -70,6 +70,10 @@ _PUBLIC_ROUTES: frozenset[tuple[str, str]] = frozenset(
         ("GET", "/api/v1/auth/oidc/authorize/{provider_id}"),
         ("GET", "/api/v1/auth/oidc/callback"),
         ("POST", "/api/v1/auth/oidc/exchange"),
+        # Connected-app token exchange — called by the app's server, not a browser. The client
+        # secret (bcrypt-verified) plus a single-use, 60 s, PKCE-bound code in the body are the auth;
+        # rate-limited per client and per IP. Refuses outright while Bambuddy auth is disabled.
+        ("POST", "/api/v1/connect/token"),
         # 2FA send + verify — issued after password check; pre-auth token in cookie is the auth.
         ("POST", "/api/v1/auth/2fa/email/send"),
         ("POST", "/api/v1/auth/2fa/verify"),

+ 5 - 0
frontend/src/App.tsx

@@ -23,6 +23,7 @@ import InventoryPage from './pages/InventoryPage';
 import { MakerworldPage } from './pages/MakerworldPage';
 import { SystemInfoPage } from './pages/SystemInfoPage';
 import { LoginPage } from './pages/LoginPage';
+import { ConnectAuthorizePage } from './pages/ConnectAuthorizePage';
 import { SetupPage } from './pages/SetupPage';
 import { NotificationsPage } from './pages/NotificationsPage';
 import { GCodeViewerPage } from './pages/GCodeViewerPage';
@@ -180,6 +181,10 @@ function App() {
                 {/* Login page */}
                 <Route path="/login" element={<LoginPage />} />
 
+                {/* "Sign in with Bambuddy" for connected apps: standalone, no layout,
+                    so it also fits inside the sidebar iframe of the app asking. */}
+                <Route path="/connect/authorize" element={<ProtectedRoute><ConnectAuthorizePage /></ProtectedRoute>} />
+
                 {/* Camera page - standalone, no layout, no WebSocket (doesn't need real-time updates) */}
                 <Route path="/camera/:printerId" element={<CameraPage />} />
 

+ 97 - 0
frontend/src/__tests__/components/ConnectedAppsSection.test.tsx

@@ -0,0 +1,97 @@
+/**
+ * Settings → API Keys → Connected apps.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { render } from '../utils';
+import { api, type ConnectedApp } from '../../api/client';
+
+const mockUseAuth = { authEnabled: true };
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => mockUseAuth };
+});
+
+import { ConnectedAppsSection } from '../../components/ConnectedAppsSection';
+
+function app(overrides: Partial<ConnectedApp> = {}): ConnectedApp {
+  return {
+    id: 1,
+    name: 'Order Desk',
+    client_id: 'bba_123',
+    redirect_uri: 'http://orders.local:8090/auth/callback',
+    enabled: true,
+    created_at: '2026-09-26T10:00:00Z',
+    last_used_at: null,
+    ...overrides,
+  };
+}
+
+beforeEach(() => {
+  mockUseAuth.authEnabled = true;
+});
+
+afterEach(() => {
+  vi.restoreAllMocks();
+});
+
+describe('ConnectedAppsSection', () => {
+  it('lists registered apps by client ID, never with a secret', async () => {
+    vi.spyOn(api, 'listConnectedApps').mockResolvedValue([app()]);
+
+    render(<ConnectedAppsSection />);
+
+    expect(await screen.findByText('Order Desk')).toBeInTheDocument();
+    expect(screen.getByText('bba_123')).toBeInTheDocument();
+    expect(screen.queryByText(/bbs_/)).not.toBeInTheDocument();
+  });
+
+  it('shows the secret once after adding an app', async () => {
+    vi.spyOn(api, 'listConnectedApps').mockResolvedValue([]);
+    const create = vi
+      .spyOn(api, 'createConnectedApp')
+      .mockResolvedValue(app({ client_secret: 'bbs_only_once' }));
+
+    render(<ConnectedAppsSection />);
+    await screen.findByText('No connected apps yet.');
+
+    await userEvent.type(screen.getByLabelText('App name'), 'Order Desk');
+    await userEvent.type(screen.getByLabelText('Callback URL'), 'http://orders.local:8090/auth/callback');
+    await userEvent.click(screen.getByRole('button', { name: 'Add app' }));
+
+    expect(create).toHaveBeenCalledWith({
+      name: 'Order Desk',
+      redirect_uri: 'http://orders.local:8090/auth/callback',
+    });
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('bbs_only_once')).toBeInTheDocument();
+
+    await userEvent.click(within(dialog).getByRole('button', { name: "I've saved it" }));
+    await waitFor(() => expect(screen.queryByText('bbs_only_once')).not.toBeInTheDocument());
+  });
+
+  it('asks before replacing the secret', async () => {
+    vi.spyOn(api, 'listConnectedApps').mockResolvedValue([app()]);
+    const rotate = vi.spyOn(api, 'rotateConnectedAppSecret').mockResolvedValue(app({ client_secret: 'bbs_new' }));
+
+    render(<ConnectedAppsSection />);
+    await userEvent.click(await screen.findByRole('button', { name: 'New secret' }));
+    expect(rotate).not.toHaveBeenCalled();
+
+    const confirm = screen.getByRole('dialog');
+    await userEvent.click(within(confirm).getByRole('button', { name: 'New secret' }));
+    expect(rotate).toHaveBeenCalledWith(1);
+    expect(await screen.findByText('bbs_new')).toBeInTheDocument();
+  });
+
+  it('explains that authentication has to be on first', async () => {
+    mockUseAuth.authEnabled = false;
+    const list = vi.spyOn(api, 'listConnectedApps');
+
+    render(<ConnectedAppsSection />);
+
+    expect(screen.getByText(/need Bambuddy authentication/)).toBeInTheDocument();
+    expect(list).not.toHaveBeenCalled();
+  });
+});

+ 149 - 0
frontend/src/__tests__/pages/ConnectAuthorizePage.test.tsx

@@ -0,0 +1,149 @@
+/**
+ * "Sign in with Bambuddy" consent page.
+ *
+ * The assertions that carry weight: the page never redirects before the
+ * backend has vouched for the callback URL, and when it does redirect it uses
+ * the callback the backend returned, with the code and the app's state.
+ */
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, waitFor } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MemoryRouter } from 'react-router-dom';
+import { api, ApiError } from '../../api/client';
+
+const mockUseAuth = { authEnabled: true, loading: false };
+vi.mock('../../contexts/AuthContext', async (importOriginal) => {
+  const actual = await importOriginal<typeof import('../../contexts/AuthContext')>();
+  return { ...actual, useAuth: () => mockUseAuth };
+});
+
+import { ConnectAuthorizePage } from '../../pages/ConnectAuthorizePage';
+
+const CALLBACK = 'http://orders.local:8090/auth/callback';
+const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
+
+function query(overrides: Record<string, string> = {}) {
+  const params = new URLSearchParams({
+    client_id: 'bba_123',
+    redirect_uri: CALLBACK,
+    state: 'st4te',
+    code_challenge: CHALLENGE,
+    code_challenge_method: 'S256',
+    ...overrides,
+  });
+  return `?${params.toString()}`;
+}
+
+function renderAt(search: string) {
+  return render(
+    <MemoryRouter initialEntries={[`/connect/authorize${search}`]}>
+      <ConnectAuthorizePage />
+    </MemoryRouter>,
+  );
+}
+
+let replace: ReturnType<typeof vi.fn>;
+const realLocation = window.location;
+
+beforeEach(() => {
+  mockUseAuth.authEnabled = true;
+  replace = vi.fn();
+  Object.defineProperty(window, 'location', {
+    configurable: true,
+    writable: true,
+    value: { ...realLocation, href: 'http://localhost:3000/connect/authorize', replace },
+  });
+});
+
+afterEach(() => {
+  Object.defineProperty(window, 'location', { configurable: true, writable: true, value: realLocation });
+  vi.restoreAllMocks();
+});
+
+describe('ConnectAuthorizePage', () => {
+  it('asks for consent, then returns to the registered callback with code and state', async () => {
+    vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
+      app_name: 'Order Desk',
+      username: 'martin',
+      already_granted: false,
+    });
+    const authorize = vi
+      .spyOn(api, 'connectAuthorize')
+      .mockResolvedValue({ code: 'c0de', redirect_uri: CALLBACK });
+
+    renderAt(query());
+
+    expect(await screen.findByText('Order Desk wants to sign you in')).toBeInTheDocument();
+    expect(screen.getByText('Signed in to Bambuddy as martin')).toBeInTheDocument();
+    expect(replace).not.toHaveBeenCalled();
+
+    await userEvent.click(screen.getByRole('button', { name: 'Allow' }));
+
+    expect(authorize).toHaveBeenCalledWith({
+      client_id: 'bba_123',
+      redirect_uri: CALLBACK,
+      code_challenge: CHALLENGE,
+      code_challenge_method: 'S256',
+    });
+    await waitFor(() => expect(replace).toHaveBeenCalledWith(`${CALLBACK}?code=c0de&state=st4te`));
+  });
+
+  it('skips the consent screen for an app the user already allowed', async () => {
+    vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
+      app_name: 'Order Desk',
+      username: 'martin',
+      already_granted: true,
+    });
+    vi.spyOn(api, 'connectAuthorize').mockResolvedValue({ code: 'c0de', redirect_uri: CALLBACK });
+
+    renderAt(query());
+
+    await waitFor(() => expect(replace).toHaveBeenCalledWith(`${CALLBACK}?code=c0de&state=st4te`));
+    expect(screen.queryByRole('button', { name: 'Allow' })).not.toBeInTheDocument();
+  });
+
+  it('cancel returns access_denied to the app', async () => {
+    vi.spyOn(api, 'getConnectAuthorizeInfo').mockResolvedValue({
+      app_name: 'Order Desk',
+      username: 'martin',
+      already_granted: false,
+    });
+    const authorize = vi.spyOn(api, 'connectAuthorize');
+
+    renderAt(query());
+    await userEvent.click(await screen.findByRole('button', { name: 'Cancel' }));
+
+    expect(replace).toHaveBeenCalledWith(`${CALLBACK}?error=access_denied&state=st4te`);
+    expect(authorize).not.toHaveBeenCalled();
+  });
+
+  it('shows an error and never redirects when the backend rejects the request', async () => {
+    vi.spyOn(api, 'getConnectAuthorizeInfo').mockRejectedValue(new ApiError('bad', 400));
+
+    renderAt(query({ redirect_uri: 'http://evil.example/steal' }));
+
+    expect(await screen.findByText("Can't sign you in")).toBeInTheDocument();
+    expect(replace).not.toHaveBeenCalled();
+  });
+
+  it('rejects a request without PKCE before asking the backend', async () => {
+    const info = vi.spyOn(api, 'getConnectAuthorizeInfo');
+
+    renderAt(query({ code_challenge_method: 'plain' }));
+
+    expect(await screen.findByText("Can't sign you in")).toBeInTheDocument();
+    expect(info).not.toHaveBeenCalled();
+    expect(replace).not.toHaveBeenCalled();
+  });
+
+  it('explains that sign-in is unavailable while authentication is off', async () => {
+    mockUseAuth.authEnabled = false;
+    const info = vi.spyOn(api, 'getConnectAuthorizeInfo');
+
+    renderAt(query());
+
+    expect(await screen.findByText(/Bambuddy authentication is turned off/)).toBeInTheDocument();
+    expect(info).not.toHaveBeenCalled();
+    expect(replace).not.toHaveBeenCalled();
+  });
+});

+ 43 - 0
frontend/src/api/client.ts

@@ -335,6 +335,25 @@ export interface LongLivedCameraToken {
   token: string | null;
 }
 
+// An external application that signs users in with their Bambuddy account.
+// `client_secret` is present only in the create / rotate responses.
+export interface ConnectedApp {
+  id: number;
+  name: string;
+  client_id: string;
+  redirect_uri: string;
+  enabled: boolean;
+  created_at: string;
+  last_used_at: string | null;
+  client_secret?: string;
+}
+
+export interface ConnectAuthorizeInfo {
+  app_name: string;
+  username: string;
+  already_granted: boolean;
+}
+
 // One row of the token-authenticated Cam Wall feed (#2531). Deliberately
 // smaller than PrinterStatus: no serial, no IP, no print filename — a kiosk URL
 // is not a secret, so the payload behind it must not be either.
@@ -6857,6 +6876,30 @@ export const api = {
   getWebSocketToken: () =>
     request<{ token: string }>('/auth/ws-token', { method: 'POST' }),
 
+  // Connected apps: sign-in to external applications with Bambuddy
+  listConnectedApps: () => request<ConnectedApp[]>('/connect/apps'),
+  createConnectedApp: (payload: { name: string; redirect_uri: string }) =>
+    request<ConnectedApp>('/connect/apps', { method: 'POST', body: JSON.stringify(payload) }),
+  updateConnectedApp: (id: number, payload: { name?: string; redirect_uri?: string; enabled?: boolean }) =>
+    request<ConnectedApp>(`/connect/apps/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
+  rotateConnectedAppSecret: (id: number) =>
+    request<ConnectedApp>(`/connect/apps/${id}/rotate-secret`, { method: 'POST' }),
+  deleteConnectedApp: (id: number) => request<void>(`/connect/apps/${id}`, { method: 'DELETE' }),
+  getConnectAuthorizeInfo: (clientId: string, redirectUri: string) =>
+    request<ConnectAuthorizeInfo>(
+      `/connect/authorize/info?client_id=${encodeURIComponent(clientId)}&redirect_uri=${encodeURIComponent(redirectUri)}`,
+    ),
+  connectAuthorize: (payload: {
+    client_id: string;
+    redirect_uri: string;
+    code_challenge: string;
+    code_challenge_method: 'S256';
+  }) =>
+    request<{ code: string; redirect_uri: string }>('/connect/authorize', {
+      method: 'POST',
+      body: JSON.stringify(payload),
+    }),
+
   // Long-lived camera tokens (#1108, #2531)
   createLongLivedCameraToken: (payload: {
     name: string;

+ 337 - 0
frontend/src/components/ConnectedAppsSection.tsx

@@ -0,0 +1,337 @@
+/**
+ * Settings → API Keys → Connected apps.
+ *
+ * Registers external applications that sign users in with their Bambuddy
+ * account ("Sign in with Bambuddy"). The client secret is shown exactly once,
+ * at creation or rotation; the list only ever shows the client ID.
+ */
+import { useState } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Copy, Plus, RefreshCw, Trash2 } from 'lucide-react';
+import { api, type ConnectedApp } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+import { useToast } from '../contexts/ToastContext';
+import { parseUTCDate } from '../utils/date';
+
+const QUERY_KEY = ['connected-apps'];
+
+function formatDate(iso: string | null): string {
+  if (!iso) return '—';
+  const d = parseUTCDate(iso);
+  return d ? d.toLocaleString() : '—';
+}
+
+async function copyToClipboard(value: string): Promise<void> {
+  // navigator.clipboard needs a secure context; LAN installs are often plain
+  // http, so fall back to a hidden textarea.
+  if (navigator.clipboard && window.isSecureContext) {
+    await navigator.clipboard.writeText(value);
+    return;
+  }
+  const ta = document.createElement('textarea');
+  ta.value = value;
+  ta.style.position = 'fixed';
+  ta.style.opacity = '0';
+  document.body.appendChild(ta);
+  try {
+    ta.select();
+    document.execCommand('copy');
+  } finally {
+    document.body.removeChild(ta);
+  }
+}
+
+function CopyField({ label, value }: { label: string; value: string }) {
+  const { t } = useTranslation();
+  const { showToast } = useToast();
+  return (
+    <div>
+      <p className="text-xs text-bambu-gray mb-1">{label}</p>
+      <div className="flex items-center gap-2">
+        <code className="flex-1 min-w-0 px-3 py-2 bg-bambu-dark rounded-md text-bambu-green text-xs break-all font-mono select-all">
+          {value}
+        </code>
+        <button
+          type="button"
+          onClick={() =>
+            copyToClipboard(value).then(
+              () => showToast(t('connectedApps.toast.copied')),
+              () => showToast(t('connectedApps.toast.copyFailed'), 'error'),
+            )
+          }
+          className="flex items-center gap-2 px-3 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90"
+        >
+          <Copy className="w-4 h-4" />
+          {t('connectedApps.copy')}
+        </button>
+      </div>
+    </div>
+  );
+}
+
+function SecretModal({ app, onClose }: { app: ConnectedApp; onClose: () => void }) {
+  const { t } = useTranslation();
+  return (
+    <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
+      <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-2xl w-full border border-bambu-green/40 space-y-4">
+        <div className="flex items-start gap-3">
+          <AlertTriangle className="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" />
+          <div>
+            <h2 className="text-lg font-semibold text-white">{t('connectedApps.secret.title', { name: app.name })}</h2>
+            <p className="text-sm text-bambu-gray mt-1">{t('connectedApps.secret.warning')}</p>
+          </div>
+        </div>
+        <CopyField label={t('connectedApps.clientId')} value={app.client_id} />
+        <CopyField label={t('connectedApps.clientSecret')} value={app.client_secret ?? ''} />
+        <div className="flex justify-end">
+          <button
+            type="button"
+            onClick={onClose}
+            className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
+          >
+            {t('connectedApps.secret.dismiss')}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+function ConfirmModal({
+  title,
+  body,
+  confirmLabel,
+  onConfirm,
+  onCancel,
+}: {
+  title: string;
+  body: string;
+  confirmLabel: string;
+  onConfirm: () => void;
+  onCancel: () => void;
+}) {
+  const { t } = useTranslation();
+  return (
+    <div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 p-4" role="dialog" aria-modal="true">
+      <div className="bg-bambu-dark-secondary rounded-lg p-6 max-w-md w-full border border-red-500/40">
+        <div className="flex items-start gap-3 mb-4">
+          <AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-0.5" />
+          <div>
+            <h2 className="text-lg font-semibold text-white">{title}</h2>
+            <p className="text-sm text-bambu-gray mt-1">{body}</p>
+          </div>
+        </div>
+        <div className="flex justify-end gap-2">
+          <button
+            type="button"
+            onClick={onCancel}
+            className="px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80"
+          >
+            {t('connectedApps.cancel')}
+          </button>
+          <button type="button" onClick={onConfirm} className="px-4 py-2 bg-red-500 text-white rounded-md hover:bg-red-600">
+            {confirmLabel}
+          </button>
+        </div>
+      </div>
+    </div>
+  );
+}
+
+export function ConnectedAppsSection() {
+  const { t } = useTranslation();
+  const { authEnabled } = useAuth();
+  const { showToast } = useToast();
+  const queryClient = useQueryClient();
+  const [name, setName] = useState('');
+  const [redirectUri, setRedirectUri] = useState('');
+  const [shownSecret, setShownSecret] = useState<ConnectedApp | null>(null);
+  const [confirm, setConfirm] = useState<{ app: ConnectedApp; action: 'rotate' | 'delete' } | null>(null);
+
+  const { data: apps = [], isLoading } = useQuery({
+    queryKey: QUERY_KEY,
+    queryFn: api.listConnectedApps,
+    enabled: authEnabled,
+  });
+
+  const onError = (err: unknown) =>
+    showToast(err instanceof Error ? err.message : t('connectedApps.toast.failed'), 'error');
+  const refresh = () => queryClient.invalidateQueries({ queryKey: QUERY_KEY });
+
+  const create = useMutation({
+    mutationFn: () => api.createConnectedApp({ name: name.trim(), redirect_uri: redirectUri.trim() }),
+    onSuccess: (app) => {
+      setName('');
+      setRedirectUri('');
+      setShownSecret(app);
+      showToast(t('connectedApps.toast.created'));
+      refresh();
+    },
+    onError,
+  });
+
+  const toggle = useMutation({
+    mutationFn: (app: ConnectedApp) => api.updateConnectedApp(app.id, { enabled: !app.enabled }),
+    onSuccess: (app) => {
+      showToast(app.enabled ? t('connectedApps.toast.enabled') : t('connectedApps.toast.disabled'));
+      refresh();
+    },
+    onError,
+  });
+
+  const rotate = useMutation({
+    mutationFn: (app: ConnectedApp) => api.rotateConnectedAppSecret(app.id),
+    onSuccess: (app) => {
+      setShownSecret(app);
+      showToast(t('connectedApps.toast.rotated'));
+      refresh();
+    },
+    onError,
+  });
+
+  const remove = useMutation({
+    mutationFn: (app: ConnectedApp) => api.deleteConnectedApp(app.id),
+    onSuccess: () => {
+      showToast(t('connectedApps.toast.deleted'));
+      refresh();
+    },
+    onError,
+  });
+
+  if (!authEnabled) {
+    return <p className="text-sm text-bambu-gray">{t('connectedApps.requiresAuth')}</p>;
+  }
+
+  const inputClass =
+    'px-3 py-2 bg-bambu-dark rounded-md text-white border border-bambu-dark-tertiary focus:border-bambu-green focus:outline-none';
+
+  return (
+    <div className="space-y-4">
+      <p className="text-sm text-bambu-gray">{t('connectedApps.description')}</p>
+
+      <form
+        onSubmit={(e) => {
+          e.preventDefault();
+          if (name.trim() && redirectUri.trim()) create.mutate();
+        }}
+        className="grid gap-3 md:grid-cols-[1fr_1.5fr_auto]"
+      >
+        <input
+          type="text"
+          maxLength={100}
+          required
+          value={name}
+          onChange={(e) => setName(e.target.value)}
+          placeholder={t('connectedApps.namePlaceholder')}
+          aria-label={t('connectedApps.name')}
+          className={inputClass}
+        />
+        <input
+          type="url"
+          maxLength={500}
+          required
+          value={redirectUri}
+          onChange={(e) => setRedirectUri(e.target.value)}
+          placeholder="http://orders.local:8090/auth/callback"
+          aria-label={t('connectedApps.callbackUrl')}
+          className={inputClass}
+        />
+        <button
+          type="submit"
+          disabled={create.isPending || !name.trim() || !redirectUri.trim()}
+          className="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('connectedApps.add')}
+        </button>
+      </form>
+      <p className="text-xs text-bambu-gray">{t('connectedApps.callbackHint')}</p>
+
+      {isLoading ? null : apps.length === 0 ? (
+        <p className="text-sm text-bambu-gray">{t('connectedApps.empty')}</p>
+      ) : (
+        <div className="overflow-x-auto">
+          <table className="w-full text-sm">
+            <thead>
+              <tr className="text-left text-bambu-gray border-b border-bambu-dark-tertiary">
+                <th className="py-2 px-3 font-medium">{t('connectedApps.name')}</th>
+                <th className="py-2 px-3 font-medium">{t('connectedApps.clientId')}</th>
+                <th className="py-2 px-3 font-medium">{t('connectedApps.callbackUrl')}</th>
+                <th className="py-2 px-3 font-medium">{t('connectedApps.lastUsed')}</th>
+                <th className="py-2 px-3" />
+              </tr>
+            </thead>
+            <tbody>
+              {apps.map((app) => (
+                <tr key={app.id} className="border-b border-bambu-dark-tertiary last:border-b-0">
+                  <td className="py-3 px-3 text-white">
+                    {app.name}
+                    {!app.enabled && (
+                      <span className="ml-2 px-2 py-0.5 text-xs rounded bg-bambu-dark-tertiary text-bambu-gray">
+                        {t('connectedApps.disabledBadge')}
+                      </span>
+                    )}
+                  </td>
+                  <td className="py-3 px-3 text-bambu-gray font-mono text-xs">{app.client_id}</td>
+                  <td className="py-3 px-3 text-bambu-gray text-xs break-all">{app.redirect_uri}</td>
+                  <td className="py-3 px-3 text-bambu-gray">{formatDate(app.last_used_at)}</td>
+                  <td className="py-3 px-3">
+                    <div className="flex justify-end gap-3 whitespace-nowrap">
+                      <button
+                        type="button"
+                        onClick={() => toggle.mutate(app)}
+                        className="text-sm text-bambu-gray hover:text-white"
+                      >
+                        {app.enabled ? t('connectedApps.disable') : t('connectedApps.enable')}
+                      </button>
+                      <button
+                        type="button"
+                        onClick={() => setConfirm({ app, action: 'rotate' })}
+                        className="inline-flex items-center gap-1 text-sm text-bambu-gray hover:text-white"
+                      >
+                        <RefreshCw className="w-4 h-4" />
+                        {t('connectedApps.rotate')}
+                      </button>
+                      <button
+                        type="button"
+                        onClick={() => setConfirm({ app, action: 'delete' })}
+                        className="inline-flex items-center gap-1 text-sm text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300"
+                      >
+                        <Trash2 className="w-4 h-4" />
+                        {t('connectedApps.delete')}
+                      </button>
+                    </div>
+                  </td>
+                </tr>
+              ))}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      {shownSecret && <SecretModal app={shownSecret} onClose={() => setShownSecret(null)} />}
+      {confirm && (
+        <ConfirmModal
+          title={
+            confirm.action === 'rotate'
+              ? t('connectedApps.confirmRotate.title')
+              : t('connectedApps.confirmDelete.title')
+          }
+          body={
+            confirm.action === 'rotate'
+              ? t('connectedApps.confirmRotate.body', { name: confirm.app.name })
+              : t('connectedApps.confirmDelete.body', { name: confirm.app.name })
+          }
+          confirmLabel={confirm.action === 'rotate' ? t('connectedApps.rotate') : t('connectedApps.delete')}
+          onCancel={() => setConfirm(null)}
+          onConfirm={() => {
+            if (confirm.action === 'rotate') rotate.mutate(confirm.app);
+            else remove.mutate(confirm.app);
+            setConfirm(null);
+          }}
+        />
+      )}
+    </div>
+  );
+}

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

@@ -7477,6 +7477,63 @@ export default {
     runNow: 'Archive jetzt löschen',
     saveFailed: 'Einstellungen konnten nicht gespeichert werden.',
   },
+  connectedApps: {
+    title: 'Verbundene Apps',
+    description: 'Apps, bei denen man sich mit dem Bambuddy-Konto anmeldet. Jede App bekommt eine Client-ID und ein Geheimnis, und Bambuddy schickt eine Anmeldung nur an die hier eingetragene Callback-URL.',
+    requiresAuth: 'Verbundene Apps brauchen die Bambuddy-Anmeldung. Aktiviere sie zuerst unter Benutzer.',
+    name: 'App-Name',
+    namePlaceholder: 'z. B. Auftragsverwaltung',
+    callbackUrl: 'Callback-URL',
+    callbackHint: 'Die Callback-URL steht in der Dokumentation der App. Sie muss exakt übereinstimmen.',
+    add: 'App hinzufügen',
+    empty: 'Noch keine verbundenen Apps.',
+    clientId: 'Client-ID',
+    clientSecret: 'Client-Geheimnis',
+    lastUsed: 'Letzte Anmeldung',
+    disabledBadge: 'Deaktiviert',
+    enable: 'Aktivieren',
+    disable: 'Deaktivieren',
+    rotate: 'Neues Geheimnis',
+    delete: 'Löschen',
+    cancel: 'Abbrechen',
+    copy: 'Kopieren',
+    secret: {
+      title: '{{name}}: Geheimnis jetzt kopieren',
+      warning: 'Trage Client-ID und Geheimnis in der App ein. Das Geheimnis wird nur dieses eine Mal angezeigt; geht es verloren, erzeuge ein neues.',
+      dismiss: 'Gespeichert',
+    },
+    confirmRotate: {
+      title: 'Neues Geheimnis erzeugen?',
+      body: '"{{name}}" kann niemanden mehr anmelden, bis du das neue Geheimnis dort einträgst.',
+    },
+    confirmDelete: {
+      title: 'App löschen?',
+      body: '"{{name}}" kann niemanden mehr anmelden. Wird sie wieder hinzugefügt, werden alle erneut um Erlaubnis gefragt.',
+    },
+    toast: {
+      created: 'App hinzugefügt',
+      enabled: 'App aktiviert',
+      disabled: 'App deaktiviert',
+      rotated: 'Neues Geheimnis erzeugt',
+      deleted: 'App gelöscht',
+      copied: 'In die Zwischenablage kopiert',
+      copyFailed: 'Kopieren fehlgeschlagen. Markiere den Text und kopiere ihn von Hand.',
+      failed: 'Etwas ist schiefgelaufen',
+    },
+    authorize: {
+      checking: 'Anmeldeanfrage wird geprüft…',
+      redirecting: 'Du wirst angemeldet…',
+      errorTitle: 'Anmeldung nicht möglich',
+      invalidRequest: 'Diese Anmeldeanfrage ist ungültig: Die App ist nicht registriert, deaktiviert oder nutzt eine andere Callback-URL. Bitte deinen Bambuddy-Admin, Einstellungen > API-Schlüssel > Verbundene Apps zu prüfen.',
+      authDisabled: 'Die Anmeldung mit Bambuddy ist nicht verfügbar, weil die Bambuddy-Anmeldung ausgeschaltet ist. Nutze stattdessen die Anmeldung der App.',
+      failed: 'Anmeldung fehlgeschlagen. Geh zurück zur App und versuche es erneut.',
+      title: '{{app}} möchte dich anmelden',
+      signedInAs: 'Bei Bambuddy angemeldet als {{username}}',
+      sharedData: '{{app}} sieht deinen Benutzernamen, deine E-Mail-Adresse, Gruppen und Berechtigungen. Dein Passwort und deine Bambuddy-Sitzung bekommt die App nicht.',
+      allow: 'Erlauben',
+      deny: 'Abbrechen',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Kamera-Stream',

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

@@ -7528,6 +7528,63 @@ export default {
     runNow: 'Purge archives now',
     saveFailed: 'Could not save auto-purge settings.',
   },
+  connectedApps: {
+    title: 'Connected Apps',
+    description: 'Apps that sign people in with their Bambuddy account. Each app gets a client ID and secret, and Bambuddy only ever sends a sign-in to the callback URL registered here.',
+    requiresAuth: 'Connected apps need Bambuddy authentication. Turn it on under Users first.',
+    name: 'App name',
+    namePlaceholder: 'e.g. Order manager',
+    callbackUrl: 'Callback URL',
+    callbackHint: 'The app\'s documentation gives its callback URL. It must match exactly.',
+    add: 'Add app',
+    empty: 'No connected apps yet.',
+    clientId: 'Client ID',
+    clientSecret: 'Client secret',
+    lastUsed: 'Last sign-in',
+    disabledBadge: 'Disabled',
+    enable: 'Enable',
+    disable: 'Disable',
+    rotate: 'New secret',
+    delete: 'Delete',
+    cancel: 'Cancel',
+    copy: 'Copy',
+    secret: {
+      title: '{{name}}: copy the secret now',
+      warning: 'Paste the client ID and secret into the app. The secret is shown only this once; if it\'s lost, create a new one.',
+      dismiss: 'I\'ve saved it',
+    },
+    confirmRotate: {
+      title: 'Create a new secret?',
+      body: '"{{name}}" stops signing people in until you paste the new secret into it.',
+    },
+    confirmDelete: {
+      title: 'Delete this app?',
+      body: '"{{name}}" can no longer sign anyone in. If it\'s added again, everyone is asked for permission again.',
+    },
+    toast: {
+      created: 'App added',
+      enabled: 'App enabled',
+      disabled: 'App disabled',
+      rotated: 'New secret created',
+      deleted: 'App deleted',
+      copied: 'Copied to clipboard',
+      copyFailed: 'Copy failed. Select the text and copy it manually.',
+      failed: 'Something went wrong',
+    },
+    authorize: {
+      checking: 'Checking the sign-in request…',
+      redirecting: 'Signing you in…',
+      errorTitle: 'Can\'t sign you in',
+      invalidRequest: 'This sign-in request isn\'t valid: the app isn\'t registered, is disabled, or uses a different callback URL. Ask your Bambuddy admin to check Settings > API Keys > Connected Apps.',
+      authDisabled: 'Sign-in with Bambuddy isn\'t available because Bambuddy authentication is turned off. Use the app\'s own login instead.',
+      failed: 'Sign-in failed. Go back to the app and try again.',
+      title: '{{app}} wants to sign you in',
+      signedInAs: 'Signed in to Bambuddy as {{username}}',
+      sharedData: '{{app}} will see your username, email address, groups and permissions. It doesn\'t get your password or your Bambuddy session.',
+      allow: 'Allow',
+      deny: 'Cancel',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Camera stream',

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

@@ -7485,6 +7485,63 @@ export default {
     runNow: 'Purgar los archivos ahora',
     saveFailed: 'No se pudieron guardar los ajustes de purga automática.',
   },
+  connectedApps: {
+    title: 'Aplicaciones conectadas',
+    description: 'Aplicaciones en las que se inicia sesión con la cuenta de Bambuddy. Cada aplicación recibe un ID de cliente y un secreto, y Bambuddy solo envía el inicio de sesión a la URL de retorno registrada aquí.',
+    requiresAuth: 'Las aplicaciones conectadas necesitan la autenticación de Bambuddy. Actívala primero en Usuarios.',
+    name: 'Nombre de la aplicación',
+    namePlaceholder: 'p. ej. Gestor de pedidos',
+    callbackUrl: 'URL de retorno',
+    callbackHint: 'La documentación de la aplicación indica su URL de retorno. Debe coincidir exactamente.',
+    add: 'Añadir aplicación',
+    empty: 'Todavía no hay aplicaciones conectadas.',
+    clientId: 'ID de cliente',
+    clientSecret: 'Secreto de cliente',
+    lastUsed: 'Último inicio de sesión',
+    disabledBadge: 'Desactivada',
+    enable: 'Activar',
+    disable: 'Desactivar',
+    rotate: 'Nuevo secreto',
+    delete: 'Eliminar',
+    cancel: 'Cancelar',
+    copy: 'Copiar',
+    secret: {
+      title: '{{name}}: copia el secreto ahora',
+      warning: 'Pega el ID de cliente y el secreto en la aplicación. El secreto solo se muestra esta vez; si se pierde, crea uno nuevo.',
+      dismiss: 'Lo he guardado',
+    },
+    confirmRotate: {
+      title: '¿Crear un secreto nuevo?',
+      body: '"{{name}}" dejará de iniciar sesiones hasta que pegues en ella el secreto nuevo.',
+    },
+    confirmDelete: {
+      title: '¿Eliminar esta aplicación?',
+      body: '"{{name}}" ya no podrá iniciar sesión de nadie. Si se vuelve a añadir, se pedirá permiso de nuevo a todos.',
+    },
+    toast: {
+      created: 'Aplicación añadida',
+      enabled: 'Aplicación activada',
+      disabled: 'Aplicación desactivada',
+      rotated: 'Secreto nuevo creado',
+      deleted: 'Aplicación eliminada',
+      copied: 'Copiado al portapapeles',
+      copyFailed: 'No se pudo copiar. Selecciona el texto y cópialo a mano.',
+      failed: 'Algo salió mal',
+    },
+    authorize: {
+      checking: 'Comprobando la solicitud de inicio de sesión…',
+      redirecting: 'Iniciando sesión…',
+      errorTitle: 'No se puede iniciar sesión',
+      invalidRequest: 'Esta solicitud de inicio de sesión no es válida: la aplicación no está registrada, está desactivada o usa otra URL de retorno. Pide a tu administrador de Bambuddy que revise Ajustes > Claves API > Aplicaciones conectadas.',
+      authDisabled: 'El inicio de sesión con Bambuddy no está disponible porque la autenticación de Bambuddy está desactivada. Usa el inicio de sesión propio de la aplicación.',
+      failed: 'Error al iniciar sesión. Vuelve a la aplicación e inténtalo de nuevo.',
+      title: '{{app}} quiere iniciar tu sesión',
+      signedInAs: 'Sesión iniciada en Bambuddy como {{username}}',
+      sharedData: '{{app}} verá tu nombre de usuario, tu correo electrónico, tus grupos y tus permisos. No obtiene tu contraseña ni tu sesión de Bambuddy.',
+      allow: 'Permitir',
+      deny: 'Cancelar',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Transmisión de cámara',

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

@@ -7465,6 +7465,63 @@ export default {
     runNow: 'Purger les archives maintenant',
     saveFailed: 'Impossible d\'enregistrer les paramètres de purge automatique.',
   },
+  connectedApps: {
+    title: 'Applications connectées',
+    description: 'Applications auxquelles on se connecte avec son compte Bambuddy. Chaque application reçoit un identifiant client et un secret, et Bambuddy n\'envoie une connexion qu\'à l\'URL de retour enregistrée ici.',
+    requiresAuth: 'Les applications connectées nécessitent l\'authentification Bambuddy. Activez-la d\'abord dans Utilisateurs.',
+    name: 'Nom de l\'application',
+    namePlaceholder: 'p. ex. Gestion des commandes',
+    callbackUrl: 'URL de retour',
+    callbackHint: 'La documentation de l\'application indique son URL de retour. Elle doit correspondre exactement.',
+    add: 'Ajouter une application',
+    empty: 'Aucune application connectée pour l\'instant.',
+    clientId: 'Identifiant client',
+    clientSecret: 'Secret client',
+    lastUsed: 'Dernière connexion',
+    disabledBadge: 'Désactivée',
+    enable: 'Activer',
+    disable: 'Désactiver',
+    rotate: 'Nouveau secret',
+    delete: 'Supprimer',
+    cancel: 'Annuler',
+    copy: 'Copier',
+    secret: {
+      title: '{{name}} : copiez le secret maintenant',
+      warning: 'Collez l\'identifiant client et le secret dans l\'application. Le secret n\'est affiché qu\'une seule fois ; s\'il est perdu, créez-en un nouveau.',
+      dismiss: 'Je l\'ai enregistré',
+    },
+    confirmRotate: {
+      title: 'Créer un nouveau secret ?',
+      body: '« {{name}} » ne pourra plus connecter personne tant que vous n\'y aurez pas collé le nouveau secret.',
+    },
+    confirmDelete: {
+      title: 'Supprimer cette application ?',
+      body: '« {{name}} » ne pourra plus connecter personne. Si elle est ajoutée à nouveau, chacun devra redonner son autorisation.',
+    },
+    toast: {
+      created: 'Application ajoutée',
+      enabled: 'Application activée',
+      disabled: 'Application désactivée',
+      rotated: 'Nouveau secret créé',
+      deleted: 'Application supprimée',
+      copied: 'Copié dans le presse-papiers',
+      copyFailed: 'Échec de la copie. Sélectionnez le texte et copiez-le manuellement.',
+      failed: 'Une erreur s\'est produite',
+    },
+    authorize: {
+      checking: 'Vérification de la demande de connexion…',
+      redirecting: 'Connexion en cours…',
+      errorTitle: 'Connexion impossible',
+      invalidRequest: 'Cette demande de connexion n\'est pas valide : l\'application n\'est pas enregistrée, est désactivée ou utilise une autre URL de retour. Demandez à votre administrateur Bambuddy de vérifier Paramètres > Clés API > Applications connectées.',
+      authDisabled: 'La connexion avec Bambuddy n\'est pas disponible car l\'authentification Bambuddy est désactivée. Utilisez plutôt la connexion propre à l\'application.',
+      failed: 'La connexion a échoué. Revenez à l\'application et réessayez.',
+      title: '{{app}} souhaite vous connecter',
+      signedInAs: 'Connecté à Bambuddy en tant que {{username}}',
+      sharedData: '{{app}} verra votre nom d\'utilisateur, votre adresse e-mail, vos groupes et vos autorisations. Elle n\'obtient ni votre mot de passe ni votre session Bambuddy.',
+      allow: 'Autoriser',
+      deny: 'Annuler',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Flux de caméra',

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

@@ -7464,6 +7464,63 @@ export default {
     runNow: 'Elimina archivi ora',
     saveFailed: 'Impossibile salvare le impostazioni di pulizia automatica.',
   },
+  connectedApps: {
+    title: 'App collegate',
+    description: 'App a cui si accede con il proprio account Bambuddy. Ogni app riceve un ID client e un segreto, e Bambuddy invia l\'accesso solo all\'URL di callback registrato qui.',
+    requiresAuth: 'Le app collegate richiedono l\'autenticazione di Bambuddy. Attivala prima in Utenti.',
+    name: 'Nome dell\'app',
+    namePlaceholder: 'es. Gestione ordini',
+    callbackUrl: 'URL di callback',
+    callbackHint: 'La documentazione dell\'app indica il suo URL di callback. Deve corrispondere esattamente.',
+    add: 'Aggiungi app',
+    empty: 'Ancora nessuna app collegata.',
+    clientId: 'ID client',
+    clientSecret: 'Segreto client',
+    lastUsed: 'Ultimo accesso',
+    disabledBadge: 'Disattivata',
+    enable: 'Attiva',
+    disable: 'Disattiva',
+    rotate: 'Nuovo segreto',
+    delete: 'Elimina',
+    cancel: 'Annulla',
+    copy: 'Copia',
+    secret: {
+      title: '{{name}}: copia subito il segreto',
+      warning: 'Incolla l\'ID client e il segreto nell\'app. Il segreto viene mostrato solo questa volta; se va perso, creane uno nuovo.',
+      dismiss: 'L\'ho salvato',
+    },
+    confirmRotate: {
+      title: 'Creare un nuovo segreto?',
+      body: '"{{name}}" non farà più accedere nessuno finché non vi incolli il nuovo segreto.',
+    },
+    confirmDelete: {
+      title: 'Eliminare questa app?',
+      body: '"{{name}}" non potrà più far accedere nessuno. Se viene aggiunta di nuovo, a tutti verrà richiesto di nuovo il permesso.',
+    },
+    toast: {
+      created: 'App aggiunta',
+      enabled: 'App attivata',
+      disabled: 'App disattivata',
+      rotated: 'Nuovo segreto creato',
+      deleted: 'App eliminata',
+      copied: 'Copiato negli appunti',
+      copyFailed: 'Copia non riuscita. Seleziona il testo e copialo manualmente.',
+      failed: 'Qualcosa è andato storto',
+    },
+    authorize: {
+      checking: 'Verifica della richiesta di accesso…',
+      redirecting: 'Accesso in corso…',
+      errorTitle: 'Impossibile accedere',
+      invalidRequest: 'Questa richiesta di accesso non è valida: l\'app non è registrata, è disattivata o usa un altro URL di callback. Chiedi all\'amministratore di Bambuddy di controllare Impostazioni > Chiavi API > App collegate.',
+      authDisabled: 'L\'accesso con Bambuddy non è disponibile perché l\'autenticazione di Bambuddy è disattivata. Usa invece l\'accesso dell\'app.',
+      failed: 'Accesso non riuscito. Torna all\'app e riprova.',
+      title: '{{app}} vuole farti accedere',
+      signedInAs: 'Accesso a Bambuddy effettuato come {{username}}',
+      sharedData: '{{app}} vedrà il tuo nome utente, l\'indirizzo email, i gruppi e i permessi. Non riceve la tua password né la tua sessione Bambuddy.',
+      allow: 'Consenti',
+      deny: 'Annulla',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Flusso della telecamera',

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

@@ -7476,6 +7476,63 @@ export default {
     runNow: 'アーカイブを今すぐ削除',
     saveFailed: '自動削除設定を保存できませんでした。',
   },
+  connectedApps: {
+    title: '連携アプリ',
+    description: 'Bambuddy アカウントでサインインするアプリです。各アプリにはクライアント ID とシークレットが発行され、Bambuddy はここに登録されたコールバック URL にだけサインインを送ります。',
+    requiresAuth: '連携アプリを使うには Bambuddy の認証が必要です。先に「ユーザー」で有効にしてください。',
+    name: 'アプリ名',
+    namePlaceholder: '例:注文管理',
+    callbackUrl: 'コールバック URL',
+    callbackHint: 'コールバック URL はアプリのドキュメントに記載されています。完全に一致する必要があります。',
+    add: 'アプリを追加',
+    empty: '連携アプリはまだありません。',
+    clientId: 'クライアント ID',
+    clientSecret: 'クライアントシークレット',
+    lastUsed: '最終サインイン',
+    disabledBadge: '無効',
+    enable: '有効にする',
+    disable: '無効にする',
+    rotate: 'シークレットを再発行',
+    delete: '削除',
+    cancel: 'キャンセル',
+    copy: 'コピー',
+    secret: {
+      title: '{{name}}:今すぐシークレットをコピーしてください',
+      warning: 'クライアント ID とシークレットをアプリに貼り付けてください。シークレットが表示されるのは今回だけです。紛失した場合は再発行してください。',
+      dismiss: '保存しました',
+    },
+    confirmRotate: {
+      title: 'シークレットを再発行しますか?',
+      body: '新しいシークレットを貼り付けるまで、「{{name}}」ではサインインできなくなります。',
+    },
+    confirmDelete: {
+      title: 'このアプリを削除しますか?',
+      body: '「{{name}}」では誰もサインインできなくなります。再度追加した場合、全員に改めて許可を求めます。',
+    },
+    toast: {
+      created: 'アプリを追加しました',
+      enabled: 'アプリを有効にしました',
+      disabled: 'アプリを無効にしました',
+      rotated: '新しいシークレットを発行しました',
+      deleted: 'アプリを削除しました',
+      copied: 'クリップボードにコピーしました',
+      copyFailed: 'コピーできませんでした。テキストを選択して手動でコピーしてください。',
+      failed: '問題が発生しました',
+    },
+    authorize: {
+      checking: 'サインイン要求を確認しています…',
+      redirecting: 'サインインしています…',
+      errorTitle: 'サインインできません',
+      invalidRequest: 'このサインイン要求は無効です。アプリが登録されていないか、無効になっているか、別のコールバック URL を使っています。Bambuddy の管理者に「設定 > API キー > 連携アプリ」を確認してもらってください。',
+      authDisabled: 'Bambuddy の認証がオフになっているため、Bambuddy でのサインインは使えません。アプリ独自のログインを使ってください。',
+      failed: 'サインインに失敗しました。アプリに戻ってもう一度お試しください。',
+      title: '{{app}} がサインインを求めています',
+      signedInAs: '{{username}} として Bambuddy にサインイン中',
+      sharedData: '{{app}} はあなたのユーザー名、メールアドレス、グループ、権限を参照します。パスワードや Bambuddy のセッションは渡されません。',
+      allow: '許可',
+      deny: 'キャンセル',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'カメラストリーム',

+ 57 - 0
frontend/src/i18n/locales/ko.ts

@@ -6921,6 +6921,63 @@ export default {
     purgeStatsLabel: '통계에서도 제거',
     purgeStatsDescription: '활성화되면 일일 정리 작업도 각 삭제된 아카이브를 빠른 통계(필라멘트, 시간, 비용, 에너지)에서 제거합니다. 기본값 비활성화 — 빠른 통계는 기여를 유지하고 파일만 디스크에서 제거됩니다.'
   },
+  connectedApps: {
+    title: '연결된 앱',
+    description: 'Bambuddy 계정으로 로그인하는 앱입니다. 각 앱에는 클라이언트 ID와 시크릿이 발급되며, Bambuddy는 여기에 등록된 콜백 URL로만 로그인을 보냅니다.',
+    requiresAuth: '연결된 앱을 사용하려면 Bambuddy 인증이 필요합니다. 먼저 사용자 메뉴에서 켜세요.',
+    name: '앱 이름',
+    namePlaceholder: '예: 주문 관리',
+    callbackUrl: '콜백 URL',
+    callbackHint: '콜백 URL은 앱 문서에 나와 있습니다. 정확히 일치해야 합니다.',
+    add: '앱 추가',
+    empty: '아직 연결된 앱이 없습니다.',
+    clientId: '클라이언트 ID',
+    clientSecret: '클라이언트 시크릿',
+    lastUsed: '마지막 로그인',
+    disabledBadge: '비활성',
+    enable: '활성화',
+    disable: '비활성화',
+    rotate: '새 시크릿',
+    delete: '삭제',
+    cancel: '취소',
+    copy: '복사',
+    secret: {
+      title: '{{name}}: 지금 시크릿을 복사하세요',
+      warning: '클라이언트 ID와 시크릿을 앱에 붙여 넣으세요. 시크릿은 이번 한 번만 표시됩니다. 잃어버리면 새로 만드세요.',
+      dismiss: '저장했습니다',
+    },
+    confirmRotate: {
+      title: '새 시크릿을 만들까요?',
+      body: '새 시크릿을 붙여 넣을 때까지 "{{name}}"(으)로 로그인할 수 없습니다.',
+    },
+    confirmDelete: {
+      title: '이 앱을 삭제할까요?',
+      body: '"{{name}}"(으)로는 더 이상 아무도 로그인할 수 없습니다. 다시 추가하면 모두에게 다시 권한을 묻습니다.',
+    },
+    toast: {
+      created: '앱을 추가했습니다',
+      enabled: '앱을 활성화했습니다',
+      disabled: '앱을 비활성화했습니다',
+      rotated: '새 시크릿을 만들었습니다',
+      deleted: '앱을 삭제했습니다',
+      copied: '클립보드에 복사했습니다',
+      copyFailed: '복사하지 못했습니다. 텍스트를 선택해 직접 복사하세요.',
+      failed: '문제가 발생했습니다',
+    },
+    authorize: {
+      checking: '로그인 요청을 확인하는 중…',
+      redirecting: '로그인하는 중…',
+      errorTitle: '로그인할 수 없습니다',
+      invalidRequest: '유효하지 않은 로그인 요청입니다. 앱이 등록되지 않았거나, 비활성화되었거나, 다른 콜백 URL을 사용합니다. Bambuddy 관리자에게 설정 > API 키 > 연결된 앱을 확인해 달라고 요청하세요.',
+      authDisabled: 'Bambuddy 인증이 꺼져 있어 Bambuddy로 로그인할 수 없습니다. 앱 자체 로그인을 사용하세요.',
+      failed: '로그인에 실패했습니다. 앱으로 돌아가 다시 시도하세요.',
+      title: '{{app}}에서 로그인을 요청합니다',
+      signedInAs: '{{username}}(으)로 Bambuddy에 로그인됨',
+      sharedData: '{{app}}은(는) 사용자 이름, 이메일 주소, 그룹, 권한을 볼 수 있습니다. 비밀번호나 Bambuddy 세션은 전달되지 않습니다.',
+      allow: '허용',
+      deny: '취소',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: '카메라 스트림',

+ 57 - 0
frontend/src/i18n/locales/nl.ts

@@ -7528,6 +7528,63 @@ export default {
     runNow: 'Archieven nu opschonen',
     saveFailed: 'Instellingen voor automatisch opschonen konden niet worden opgeslagen.',
   },
+  connectedApps: {
+    title: 'Gekoppelde apps',
+    description: 'Apps waarin je inlogt met je Bambuddy-account. Elke app krijgt een client-ID en een geheim, en Bambuddy stuurt een aanmelding alleen naar de callback-URL die hier is geregistreerd.',
+    requiresAuth: 'Gekoppelde apps hebben Bambuddy-authenticatie nodig. Zet die eerst aan onder Gebruikers.',
+    name: 'Naam van de app',
+    namePlaceholder: 'bijv. Orderbeheer',
+    callbackUrl: 'Callback-URL',
+    callbackHint: 'De documentatie van de app vermeldt de callback-URL. Die moet exact overeenkomen.',
+    add: 'App toevoegen',
+    empty: 'Nog geen gekoppelde apps.',
+    clientId: 'Client-ID',
+    clientSecret: 'Clientgeheim',
+    lastUsed: 'Laatste aanmelding',
+    disabledBadge: 'Uitgeschakeld',
+    enable: 'Inschakelen',
+    disable: 'Uitschakelen',
+    rotate: 'Nieuw geheim',
+    delete: 'Verwijderen',
+    cancel: 'Annuleren',
+    copy: 'Kopiëren',
+    secret: {
+      title: '{{name}}: kopieer het geheim nu',
+      warning: 'Plak het client-ID en het geheim in de app. Het geheim wordt alleen deze ene keer getoond; ben je het kwijt, maak dan een nieuw aan.',
+      dismiss: 'Ik heb het bewaard',
+    },
+    confirmRotate: {
+      title: 'Nieuw geheim aanmaken?',
+      body: '"{{name}}" kan niemand meer aanmelden totdat je het nieuwe geheim erin plakt.',
+    },
+    confirmDelete: {
+      title: 'Deze app verwijderen?',
+      body: '"{{name}}" kan niemand meer aanmelden. Wordt de app opnieuw toegevoegd, dan wordt iedereen opnieuw om toestemming gevraagd.',
+    },
+    toast: {
+      created: 'App toegevoegd',
+      enabled: 'App ingeschakeld',
+      disabled: 'App uitgeschakeld',
+      rotated: 'Nieuw geheim aangemaakt',
+      deleted: 'App verwijderd',
+      copied: 'Gekopieerd naar klembord',
+      copyFailed: 'Kopiëren mislukt. Selecteer de tekst en kopieer die handmatig.',
+      failed: 'Er ging iets mis',
+    },
+    authorize: {
+      checking: 'Aanmeldverzoek controleren…',
+      redirecting: 'Je wordt aangemeld…',
+      errorTitle: 'Aanmelden niet mogelijk',
+      invalidRequest: 'Dit aanmeldverzoek is ongeldig: de app is niet geregistreerd, is uitgeschakeld of gebruikt een andere callback-URL. Vraag je Bambuddy-beheerder om Instellingen > API-sleutels > Gekoppelde apps te controleren.',
+      authDisabled: 'Aanmelden met Bambuddy is niet beschikbaar omdat Bambuddy-authenticatie is uitgeschakeld. Gebruik in plaats daarvan de eigen aanmelding van de app.',
+      failed: 'Aanmelden mislukt. Ga terug naar de app en probeer het opnieuw.',
+      title: '{{app}} wil je aanmelden',
+      signedInAs: 'Aangemeld bij Bambuddy als {{username}}',
+      sharedData: '{{app}} ziet je gebruikersnaam, e-mailadres, groepen en rechten. De app krijgt je wachtwoord en je Bambuddy-sessie niet.',
+      allow: 'Toestaan',
+      deny: 'Annuleren',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Camerastream',

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

@@ -7464,6 +7464,63 @@ export default {
     runNow: 'Limpar arquivos agora',
     saveFailed: 'Não foi possível salvar as configurações de limpeza automática.',
   },
+  connectedApps: {
+    title: 'Apps conectados',
+    description: 'Apps em que se entra com a conta do Bambuddy. Cada app recebe um ID de cliente e um segredo, e o Bambuddy só envia o login para a URL de retorno registrada aqui.',
+    requiresAuth: 'Apps conectados precisam da autenticação do Bambuddy. Ative-a primeiro em Usuários.',
+    name: 'Nome do app',
+    namePlaceholder: 'ex.: Gerenciador de pedidos',
+    callbackUrl: 'URL de retorno',
+    callbackHint: 'A documentação do app informa a URL de retorno. Ela precisa ser exatamente igual.',
+    add: 'Adicionar app',
+    empty: 'Nenhum app conectado ainda.',
+    clientId: 'ID do cliente',
+    clientSecret: 'Segredo do cliente',
+    lastUsed: 'Último login',
+    disabledBadge: 'Desativado',
+    enable: 'Ativar',
+    disable: 'Desativar',
+    rotate: 'Novo segredo',
+    delete: 'Excluir',
+    cancel: 'Cancelar',
+    copy: 'Copiar',
+    secret: {
+      title: '{{name}}: copie o segredo agora',
+      warning: 'Cole o ID do cliente e o segredo no app. O segredo só aparece desta vez; se ele se perder, crie um novo.',
+      dismiss: 'Já salvei',
+    },
+    confirmRotate: {
+      title: 'Criar um novo segredo?',
+      body: '"{{name}}" deixa de fazer login até você colar o novo segredo nele.',
+    },
+    confirmDelete: {
+      title: 'Excluir este app?',
+      body: '"{{name}}" não poderá mais fazer login de ninguém. Se for adicionado de novo, todos terão de dar permissão outra vez.',
+    },
+    toast: {
+      created: 'App adicionado',
+      enabled: 'App ativado',
+      disabled: 'App desativado',
+      rotated: 'Novo segredo criado',
+      deleted: 'App excluído',
+      copied: 'Copiado para a área de transferência',
+      copyFailed: 'Não foi possível copiar. Selecione o texto e copie manualmente.',
+      failed: 'Algo deu errado',
+    },
+    authorize: {
+      checking: 'Verificando a solicitação de login…',
+      redirecting: 'Fazendo seu login…',
+      errorTitle: 'Não foi possível entrar',
+      invalidRequest: 'Esta solicitação de login não é válida: o app não está registrado, está desativado ou usa outra URL de retorno. Peça ao administrador do Bambuddy para verificar Configurações > Chaves de API > Apps conectados.',
+      authDisabled: 'O login com o Bambuddy não está disponível porque a autenticação do Bambuddy está desligada. Use o login próprio do app.',
+      failed: 'Falha no login. Volte ao app e tente de novo.',
+      title: '{{app}} quer fazer seu login',
+      signedInAs: 'Conectado ao Bambuddy como {{username}}',
+      sharedData: '{{app}} verá seu nome de usuário, e-mail, grupos e permissões. Ele não recebe sua senha nem sua sessão do Bambuddy.',
+      allow: 'Permitir',
+      deny: 'Cancelar',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Transmissão da câmera',

+ 57 - 0
frontend/src/i18n/locales/ru.ts

@@ -7102,6 +7102,63 @@ export default {
     runNow: "Очистить архив сейчас",
     saveFailed: "Не удалось сохранить настройки автоочистки.",
   },
+  connectedApps: {
+    title: 'Подключённые приложения',
+    description: 'Приложения, в которые входят с учётной записью Bambuddy. Каждое приложение получает идентификатор клиента и секрет, а Bambuddy отправляет вход только на зарегистрированный здесь URL обратного вызова.',
+    requiresAuth: 'Для подключённых приложений нужна аутентификация Bambuddy. Сначала включите её в разделе «Пользователи».',
+    name: 'Название приложения',
+    namePlaceholder: 'например, Управление заказами',
+    callbackUrl: 'URL обратного вызова',
+    callbackHint: 'URL обратного вызова указан в документации приложения. Он должен совпадать в точности.',
+    add: 'Добавить приложение',
+    empty: 'Подключённых приложений пока нет.',
+    clientId: 'Идентификатор клиента',
+    clientSecret: 'Секрет клиента',
+    lastUsed: 'Последний вход',
+    disabledBadge: 'Отключено',
+    enable: 'Включить',
+    disable: 'Отключить',
+    rotate: 'Новый секрет',
+    delete: 'Удалить',
+    cancel: 'Отмена',
+    copy: 'Копировать',
+    secret: {
+      title: '{{name}}: скопируйте секрет сейчас',
+      warning: 'Вставьте идентификатор клиента и секрет в приложение. Секрет показывается только сейчас; если он потерян, создайте новый.',
+      dismiss: 'Я сохранил',
+    },
+    confirmRotate: {
+      title: 'Создать новый секрет?',
+      body: '«{{name}}» не сможет выполнять вход, пока вы не вставите в него новый секрет.',
+    },
+    confirmDelete: {
+      title: 'Удалить это приложение?',
+      body: 'Через «{{name}}» больше никто не сможет войти. Если добавить его снова, у всех снова спросят разрешение.',
+    },
+    toast: {
+      created: 'Приложение добавлено',
+      enabled: 'Приложение включено',
+      disabled: 'Приложение отключено',
+      rotated: 'Новый секрет создан',
+      deleted: 'Приложение удалено',
+      copied: 'Скопировано в буфер обмена',
+      copyFailed: 'Не удалось скопировать. Выделите текст и скопируйте вручную.',
+      failed: 'Что-то пошло не так',
+    },
+    authorize: {
+      checking: 'Проверка запроса на вход…',
+      redirecting: 'Выполняется вход…',
+      errorTitle: 'Не удаётся войти',
+      invalidRequest: 'Этот запрос на вход недействителен: приложение не зарегистрировано, отключено или использует другой URL обратного вызова. Попросите администратора Bambuddy проверить «Настройки > API-ключи > Подключённые приложения».',
+      authDisabled: 'Вход через Bambuddy недоступен, потому что аутентификация Bambuddy отключена. Используйте собственный вход приложения.',
+      failed: 'Не удалось войти. Вернитесь в приложение и попробуйте ещё раз.',
+      title: '{{app}} хочет выполнить ваш вход',
+      signedInAs: 'Вход в Bambuddy выполнен как {{username}}',
+      sharedData: '{{app}} увидит ваше имя пользователя, адрес электронной почты, группы и права. Ваш пароль и сеанс Bambuddy приложение не получит.',
+      allow: 'Разрешить',
+      deny: 'Отмена',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: "Поток камеры",

+ 57 - 0
frontend/src/i18n/locales/sv.ts

@@ -7528,6 +7528,63 @@ errors: {
     runNow: 'Rensa arkiv nu',
     saveFailed: 'Kunde inte spara inställningar för auto-rensning.',
   },
+  connectedApps: {
+    title: 'Anslutna appar',
+    description: 'Appar där man loggar in med sitt Bambuddy-konto. Varje app får ett klient-ID och en hemlighet, och Bambuddy skickar bara en inloggning till den återanrops-URL som registrerats här.',
+    requiresAuth: 'Anslutna appar kräver Bambuddy-autentisering. Slå på den under Användare först.',
+    name: 'Appens namn',
+    namePlaceholder: 't.ex. Orderhantering',
+    callbackUrl: 'Återanrops-URL',
+    callbackHint: 'Appens dokumentation anger dess återanrops-URL. Den måste stämma exakt.',
+    add: 'Lägg till app',
+    empty: 'Inga anslutna appar ännu.',
+    clientId: 'Klient-ID',
+    clientSecret: 'Klienthemlighet',
+    lastUsed: 'Senaste inloggning',
+    disabledBadge: 'Inaktiverad',
+    enable: 'Aktivera',
+    disable: 'Inaktivera',
+    rotate: 'Ny hemlighet',
+    delete: 'Ta bort',
+    cancel: 'Avbryt',
+    copy: 'Kopiera',
+    secret: {
+      title: '{{name}}: kopiera hemligheten nu',
+      warning: 'Klistra in klient-ID och hemlighet i appen. Hemligheten visas bara den här gången; om den tappas bort, skapa en ny.',
+      dismiss: 'Jag har sparat den',
+    },
+    confirmRotate: {
+      title: 'Skapa en ny hemlighet?',
+      body: '"{{name}}" kan inte logga in någon förrän du klistrar in den nya hemligheten i den.',
+    },
+    confirmDelete: {
+      title: 'Ta bort den här appen?',
+      body: '"{{name}}" kan inte längre logga in någon. Om den läggs till igen tillfrågas alla om tillåtelse på nytt.',
+    },
+    toast: {
+      created: 'App tillagd',
+      enabled: 'App aktiverad',
+      disabled: 'App inaktiverad',
+      rotated: 'Ny hemlighet skapad',
+      deleted: 'App borttagen',
+      copied: 'Kopierat till urklipp',
+      copyFailed: 'Kopieringen misslyckades. Markera texten och kopiera den manuellt.',
+      failed: 'Något gick fel',
+    },
+    authorize: {
+      checking: 'Kontrollerar inloggningsbegäran…',
+      redirecting: 'Loggar in dig…',
+      errorTitle: 'Det går inte att logga in',
+      invalidRequest: 'Den här inloggningsbegäran är ogiltig: appen är inte registrerad, är inaktiverad eller använder en annan återanrops-URL. Be din Bambuddy-administratör kontrollera Inställningar > API-nycklar > Anslutna appar.',
+      authDisabled: 'Inloggning med Bambuddy är inte tillgänglig eftersom Bambuddy-autentiseringen är avstängd. Använd appens egen inloggning i stället.',
+      failed: 'Inloggningen misslyckades. Gå tillbaka till appen och försök igen.',
+      title: '{{app}} vill logga in dig',
+      signedInAs: 'Inloggad i Bambuddy som {{username}}',
+      sharedData: '{{app}} ser ditt användarnamn, din e-postadress, dina grupper och behörigheter. Appen får inte ditt lösenord eller din Bambuddy-session.',
+      allow: 'Tillåt',
+      deny: 'Avbryt',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Kameraström',

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

@@ -7416,6 +7416,63 @@ export default {
     runNow: 'Arşivleri şimdi temizle',
     saveFailed: 'Otomatik temizleme ayarları kaydedilemedi.',
   },
+  connectedApps: {
+    title: 'Bağlı Uygulamalar',
+    description: 'Bambuddy hesabıyla oturum açılan uygulamalar. Her uygulamaya bir istemci kimliği ve gizli anahtar verilir; Bambuddy oturum açmayı yalnızca burada kayıtlı geri çağırma URL\'sine gönderir.',
+    requiresAuth: 'Bağlı uygulamalar Bambuddy kimlik doğrulaması gerektirir. Önce Kullanıcılar bölümünden açın.',
+    name: 'Uygulama adı',
+    namePlaceholder: 'ör. Sipariş yönetimi',
+    callbackUrl: 'Geri çağırma URL\'si',
+    callbackHint: 'Geri çağırma URL\'si uygulamanın belgelerinde yazar. Tam olarak eşleşmelidir.',
+    add: 'Uygulama ekle',
+    empty: 'Henüz bağlı uygulama yok.',
+    clientId: 'İstemci kimliği',
+    clientSecret: 'İstemci gizli anahtarı',
+    lastUsed: 'Son oturum açma',
+    disabledBadge: 'Devre dışı',
+    enable: 'Etkinleştir',
+    disable: 'Devre dışı bırak',
+    rotate: 'Yeni gizli anahtar',
+    delete: 'Sil',
+    cancel: 'İptal',
+    copy: 'Kopyala',
+    secret: {
+      title: '{{name}}: gizli anahtarı şimdi kopyalayın',
+      warning: 'İstemci kimliğini ve gizli anahtarı uygulamaya yapıştırın. Gizli anahtar yalnızca bu kez gösterilir; kaybolursa yenisini oluşturun.',
+      dismiss: 'Kaydettim',
+    },
+    confirmRotate: {
+      title: 'Yeni gizli anahtar oluşturulsun mu?',
+      body: 'Yeni gizli anahtarı yapıştırana kadar "{{name}}" ile oturum açılamaz.',
+    },
+    confirmDelete: {
+      title: 'Bu uygulama silinsin mi?',
+      body: '"{{name}}" artık kimsenin oturumunu açamaz. Yeniden eklenirse herkesten tekrar izin istenir.',
+    },
+    toast: {
+      created: 'Uygulama eklendi',
+      enabled: 'Uygulama etkinleştirildi',
+      disabled: 'Uygulama devre dışı bırakıldı',
+      rotated: 'Yeni gizli anahtar oluşturuldu',
+      deleted: 'Uygulama silindi',
+      copied: 'Panoya kopyalandı',
+      copyFailed: 'Kopyalanamadı. Metni seçip elle kopyalayın.',
+      failed: 'Bir şeyler ters gitti',
+    },
+    authorize: {
+      checking: 'Oturum açma isteği kontrol ediliyor…',
+      redirecting: 'Oturumunuz açılıyor…',
+      errorTitle: 'Oturum açılamıyor',
+      invalidRequest: 'Bu oturum açma isteği geçersiz: uygulama kayıtlı değil, devre dışı ya da farklı bir geri çağırma URL\'si kullanıyor. Bambuddy yöneticinizden Ayarlar > API Anahtarları > Bağlı Uygulamalar bölümünü kontrol etmesini isteyin.',
+      authDisabled: 'Bambuddy kimlik doğrulaması kapalı olduğu için Bambuddy ile oturum açma kullanılamıyor. Bunun yerine uygulamanın kendi girişini kullanın.',
+      failed: 'Oturum açılamadı. Uygulamaya dönüp tekrar deneyin.',
+      title: '{{app}} oturumunuzu açmak istiyor',
+      signedInAs: 'Bambuddy\'de {{username}} olarak oturum açıldı',
+      sharedData: '{{app}} kullanıcı adınızı, e-posta adresinizi, gruplarınızı ve izinlerinizi görecek. Parolanızı ve Bambuddy oturumunuzu almaz.',
+      allow: 'İzin ver',
+      deny: 'İptal',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: 'Kamera akışı',

+ 57 - 0
frontend/src/i18n/locales/uk.ts

@@ -7520,6 +7520,63 @@ export default {
     runNow: "Очистити архіви зараз",
     saveFailed: "Не вдалося зберегти налаштування автоматичного очищення.",
   },
+  connectedApps: {
+    title: 'Підключені застосунки',
+    description: 'Застосунки, у які входять з обліковим записом Bambuddy. Кожен застосунок отримує ідентифікатор клієнта й секрет, а Bambuddy надсилає вхід лише на зареєстровану тут URL-адресу зворотного виклику.',
+    requiresAuth: 'Для підключених застосунків потрібна автентифікація Bambuddy. Спершу увімкніть її в розділі «Користувачі».',
+    name: 'Назва застосунку',
+    namePlaceholder: 'напр., Керування замовленнями',
+    callbackUrl: 'URL зворотного виклику',
+    callbackHint: 'URL зворотного виклику вказано в документації застосунку. Він має збігатися точно.',
+    add: 'Додати застосунок',
+    empty: 'Підключених застосунків поки немає.',
+    clientId: 'Ідентифікатор клієнта',
+    clientSecret: 'Секрет клієнта',
+    lastUsed: 'Останній вхід',
+    disabledBadge: 'Вимкнено',
+    enable: 'Увімкнути',
+    disable: 'Вимкнути',
+    rotate: 'Новий секрет',
+    delete: 'Видалити',
+    cancel: 'Скасувати',
+    copy: 'Копіювати',
+    secret: {
+      title: '{{name}}: скопіюйте секрет зараз',
+      warning: 'Вставте ідентифікатор клієнта й секрет у застосунок. Секрет показується лише цього разу; якщо його втрачено, створіть новий.',
+      dismiss: 'Я зберіг',
+    },
+    confirmRotate: {
+      title: 'Створити новий секрет?',
+      body: '«{{name}}» не зможе виконувати вхід, доки ви не вставите в нього новий секрет.',
+    },
+    confirmDelete: {
+      title: 'Видалити цей застосунок?',
+      body: 'Через «{{name}}» більше ніхто не зможе увійти. Якщо додати його знову, у всіх знову запитають дозвіл.',
+    },
+    toast: {
+      created: 'Застосунок додано',
+      enabled: 'Застосунок увімкнено',
+      disabled: 'Застосунок вимкнено',
+      rotated: 'Новий секрет створено',
+      deleted: 'Застосунок видалено',
+      copied: 'Скопійовано в буфер обміну',
+      copyFailed: 'Не вдалося скопіювати. Виділіть текст і скопіюйте вручну.',
+      failed: 'Щось пішло не так',
+    },
+    authorize: {
+      checking: 'Перевірка запиту на вхід…',
+      redirecting: 'Виконується вхід…',
+      errorTitle: 'Не вдається увійти',
+      invalidRequest: 'Цей запит на вхід недійсний: застосунок не зареєстровано, вимкнено або він використовує іншу URL-адресу зворотного виклику. Попросіть адміністратора Bambuddy перевірити «Налаштування > API-ключі > Підключені застосунки».',
+      authDisabled: 'Вхід через Bambuddy недоступний, бо автентифікацію Bambuddy вимкнено. Використовуйте власний вхід застосунку.',
+      failed: 'Не вдалося увійти. Поверніться до застосунку й спробуйте ще раз.',
+      title: '{{app}} хоче виконати ваш вхід',
+      signedInAs: 'Вхід у Bambuddy виконано як {{username}}',
+      sharedData: '{{app}} бачитиме ваше ім\'я користувача, адресу електронної пошти, групи та дозволи. Ваш пароль і сеанс Bambuddy застосунок не отримає.',
+      allow: 'Дозволити',
+      deny: 'Скасувати',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: "Потік камери",

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

@@ -7462,6 +7462,63 @@ export default {
     runNow: '立即清除归档',
     saveFailed: '无法保存自动清除设置。',
   },
+  connectedApps: {
+    title: '已连接的应用',
+    description: '使用 Bambuddy 账户登录的应用。每个应用会获得一个客户端 ID 和密钥,Bambuddy 只会把登录发送到此处登记的回调 URL。',
+    requiresAuth: '已连接的应用需要启用 Bambuddy 身份验证。请先在“用户”中开启。',
+    name: '应用名称',
+    namePlaceholder: '例如:订单管理',
+    callbackUrl: '回调 URL',
+    callbackHint: '回调 URL 见应用的文档,必须完全一致。',
+    add: '添加应用',
+    empty: '还没有已连接的应用。',
+    clientId: '客户端 ID',
+    clientSecret: '客户端密钥',
+    lastUsed: '最近登录',
+    disabledBadge: '已停用',
+    enable: '启用',
+    disable: '停用',
+    rotate: '新密钥',
+    delete: '删除',
+    cancel: '取消',
+    copy: '复制',
+    secret: {
+      title: '{{name}}:请立即复制密钥',
+      warning: '请把客户端 ID 和密钥粘贴到应用中。密钥只显示这一次;如果丢失,请重新生成。',
+      dismiss: '我已保存',
+    },
+    confirmRotate: {
+      title: '生成新密钥?',
+      body: '在你把新密钥粘贴进去之前,“{{name}}”将无法让任何人登录。',
+    },
+    confirmDelete: {
+      title: '删除此应用?',
+      body: '“{{name}}”将无法再让任何人登录。如果重新添加,所有人都需要再次授权。',
+    },
+    toast: {
+      created: '已添加应用',
+      enabled: '已启用应用',
+      disabled: '已停用应用',
+      rotated: '已生成新密钥',
+      deleted: '已删除应用',
+      copied: '已复制到剪贴板',
+      copyFailed: '复制失败。请选中文字后手动复制。',
+      failed: '出错了',
+    },
+    authorize: {
+      checking: '正在检查登录请求…',
+      redirecting: '正在为你登录…',
+      errorTitle: '无法登录',
+      invalidRequest: '此登录请求无效:应用未登记、已停用,或使用了不同的回调 URL。请联系 Bambuddy 管理员检查“设置 > API 密钥 > 已连接的应用”。',
+      authDisabled: '由于 Bambuddy 身份验证已关闭,无法使用 Bambuddy 登录。请改用应用自带的登录。',
+      failed: '登录失败。请返回应用重试。',
+      title: '{{app}} 请求为你登录',
+      signedInAs: '已以 {{username}} 身份登录 Bambuddy',
+      sharedData: '{{app}} 将看到你的用户名、电子邮件地址、用户组和权限。它不会获得你的密码或 Bambuddy 会话。',
+      allow: '允许',
+      deny: '取消',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: '摄像头视频流',

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

@@ -7462,6 +7462,63 @@ export default {
     runNow: '立即清除歸檔',
     saveFailed: '無法儲存自動清除設定。',
   },
+  connectedApps: {
+    title: '已連結的應用程式',
+    description: '使用 Bambuddy 帳號登入的應用程式。每個應用程式會取得一組用戶端 ID 與密鑰,Bambuddy 只會把登入傳送到此處登記的回呼 URL。',
+    requiresAuth: '已連結的應用程式需要啟用 Bambuddy 驗證。請先在「使用者」中開啟。',
+    name: '應用程式名稱',
+    namePlaceholder: '例如:訂單管理',
+    callbackUrl: '回呼 URL',
+    callbackHint: '回呼 URL 請見應用程式的說明文件,必須完全相符。',
+    add: '新增應用程式',
+    empty: '尚無已連結的應用程式。',
+    clientId: '用戶端 ID',
+    clientSecret: '用戶端密鑰',
+    lastUsed: '最近登入',
+    disabledBadge: '已停用',
+    enable: '啟用',
+    disable: '停用',
+    rotate: '新密鑰',
+    delete: '刪除',
+    cancel: '取消',
+    copy: '複製',
+    secret: {
+      title: '{{name}}:請立即複製密鑰',
+      warning: '請把用戶端 ID 與密鑰貼到應用程式中。密鑰只會顯示這一次;若遺失,請重新產生。',
+      dismiss: '我已儲存',
+    },
+    confirmRotate: {
+      title: '產生新密鑰?',
+      body: '在你把新密鑰貼進去之前,「{{name}}」將無法讓任何人登入。',
+    },
+    confirmDelete: {
+      title: '刪除此應用程式?',
+      body: '「{{name}}」將無法再讓任何人登入。若重新新增,所有人都需要再次授權。',
+    },
+    toast: {
+      created: '已新增應用程式',
+      enabled: '已啟用應用程式',
+      disabled: '已停用應用程式',
+      rotated: '已產生新密鑰',
+      deleted: '已刪除應用程式',
+      copied: '已複製到剪貼簿',
+      copyFailed: '複製失敗。請選取文字後手動複製。',
+      failed: '發生錯誤',
+    },
+    authorize: {
+      checking: '正在檢查登入要求…',
+      redirecting: '正在為你登入…',
+      errorTitle: '無法登入',
+      invalidRequest: '此登入要求無效:應用程式未登記、已停用,或使用了不同的回呼 URL。請聯絡 Bambuddy 管理員檢查「設定 > API 金鑰 > 已連結的應用程式」。',
+      authDisabled: '由於 Bambuddy 驗證已關閉,無法使用 Bambuddy 登入。請改用應用程式本身的登入。',
+      failed: '登入失敗。請返回應用程式再試一次。',
+      title: '{{app}} 要求為你登入',
+      signedInAs: '已以 {{username}} 身分登入 Bambuddy',
+      sharedData: '{{app}} 將看到你的使用者名稱、電子郵件地址、群組與權限。它不會取得你的密碼或 Bambuddy 工作階段。',
+      allow: '允許',
+      deny: '取消',
+    },
+  },
   cameraTokens: {
     scope: {
       camera_stream: '攝影機串流',

+ 164 - 0
frontend/src/pages/ConnectAuthorizePage.tsx

@@ -0,0 +1,164 @@
+/**
+ * "Sign in with Bambuddy" for connected apps.
+ *
+ * An app sends the browser here with its client_id, callback URL, PKCE
+ * challenge and state. The page runs with the user's normal session: it asks
+ * the backend whether the request is valid, shows a one-time consent screen,
+ * and sends the browser back to the app with a single-use code.
+ *
+ * It never redirects anywhere until the backend has confirmed that the
+ * callback URL is the one registered for that app, so it can't be used as an
+ * open redirect. Every error before that point is shown here instead.
+ */
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSearchParams } from 'react-router-dom';
+import { AlertTriangle, Link2, Loader2 } from 'lucide-react';
+import { api, ApiError, type ConnectAuthorizeInfo } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+type Phase =
+  | { kind: 'loading' }
+  | { kind: 'consent'; info: ConnectAuthorizeInfo }
+  | { kind: 'redirecting' }
+  | { kind: 'error'; message: string };
+
+function withParams(base: string, params: Record<string, string>): string {
+  const url = new URL(base);
+  for (const [key, value] of Object.entries(params)) {
+    if (value) url.searchParams.set(key, value);
+  }
+  return url.toString();
+}
+
+export function ConnectAuthorizePage() {
+  const { t } = useTranslation();
+  const { authEnabled, loading } = useAuth();
+  const [searchParams] = useSearchParams();
+  const [phase, setPhase] = useState<Phase>({ kind: 'loading' });
+  const started = useRef(false);
+
+  const clientId = searchParams.get('client_id') ?? '';
+  const redirectUri = searchParams.get('redirect_uri') ?? '';
+  const state = searchParams.get('state') ?? '';
+  const codeChallenge = searchParams.get('code_challenge') ?? '';
+  const codeChallengeMethod = searchParams.get('code_challenge_method') ?? '';
+
+  const approve = async () => {
+    setPhase({ kind: 'redirecting' });
+    try {
+      const result = await api.connectAuthorize({
+        client_id: clientId,
+        redirect_uri: redirectUri,
+        code_challenge: codeChallenge,
+        code_challenge_method: 'S256',
+      });
+      // Use the callback the backend returned, not the query parameter.
+      window.location.replace(withParams(result.redirect_uri, { code: result.code, state }));
+    } catch (err) {
+      setPhase({
+        kind: 'error',
+        message: err instanceof Error ? err.message : t('connectedApps.authorize.failed'),
+      });
+    }
+  };
+
+  const deny = () => {
+    // Safe: we only reach consent after the backend matched redirectUri
+    // against the app's registration.
+    window.location.replace(withParams(redirectUri, { error: 'access_denied', state }));
+  };
+
+  useEffect(() => {
+    if (loading || started.current) return;
+    started.current = true;
+
+    if (!authEnabled) {
+      setPhase({ kind: 'error', message: t('connectedApps.authorize.authDisabled') });
+      return;
+    }
+    if (!clientId || !redirectUri || !codeChallenge || codeChallengeMethod !== 'S256') {
+      setPhase({ kind: 'error', message: t('connectedApps.authorize.invalidRequest') });
+      return;
+    }
+
+    api
+      .getConnectAuthorizeInfo(clientId, redirectUri)
+      .then((info) => {
+        if (info.already_granted) {
+          void approve();
+        } else {
+          setPhase({ kind: 'consent', info });
+        }
+      })
+      .catch((err) => {
+        const disabled = err instanceof ApiError && err.status === 409;
+        setPhase({
+          kind: 'error',
+          message: disabled ? t('connectedApps.authorize.authDisabled') : t('connectedApps.authorize.invalidRequest'),
+        });
+      });
+    // approve() reads the same query parameters; running this once is the point.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [loading, authEnabled]);
+
+  return (
+    <div className="min-h-screen flex items-center justify-center bg-bambu-dark p-4">
+      <div className="max-w-md w-full space-y-6 p-8 bg-gradient-to-br from-bambu-card to-bambu-dark-secondary rounded-xl border border-bambu-dark-tertiary shadow-lg">
+        {(phase.kind === 'loading' || phase.kind === 'redirecting') && (
+          <div className="flex flex-col items-center gap-3 text-bambu-gray" role="status">
+            <Loader2 className="w-8 h-8 animate-spin text-bambu-green" />
+            <p>
+              {phase.kind === 'loading'
+                ? t('connectedApps.authorize.checking')
+                : t('connectedApps.authorize.redirecting')}
+            </p>
+          </div>
+        )}
+
+        {phase.kind === 'error' && (
+          <div className="text-center space-y-3">
+            <AlertTriangle className="w-10 h-10 mx-auto text-yellow-600 dark:text-yellow-400" />
+            <h1 className="text-xl font-semibold text-white">{t('connectedApps.authorize.errorTitle')}</h1>
+            <p className="text-sm text-bambu-gray">{phase.message}</p>
+          </div>
+        )}
+
+        {phase.kind === 'consent' && (
+          <>
+            <div className="text-center space-y-3">
+              <div className="w-14 h-14 mx-auto rounded-full bg-bambu-green/20 flex items-center justify-center">
+                <Link2 className="w-7 h-7 text-bambu-green" />
+              </div>
+              <h1 className="text-xl font-semibold text-white text-balance">
+                {t('connectedApps.authorize.title', { app: phase.info.app_name })}
+              </h1>
+              <p className="text-sm text-bambu-gray">
+                {t('connectedApps.authorize.signedInAs', { username: phase.info.username })}
+              </p>
+            </div>
+            <p className="text-sm text-bambu-gray">
+              {t('connectedApps.authorize.sharedData', { app: phase.info.app_name })}
+            </p>
+            <div className="flex gap-3">
+              <button
+                type="button"
+                onClick={deny}
+                className="flex-1 px-4 py-2 bg-bambu-dark-tertiary text-white rounded-md hover:bg-bambu-dark-tertiary/80 focus:outline-none focus-visible:ring-2 focus-visible:ring-bambu-green"
+              >
+                {t('connectedApps.authorize.deny')}
+              </button>
+              <button
+                type="button"
+                onClick={() => void approve()}
+                className="flex-1 px-4 py-2 bg-bambu-green text-white rounded-md hover:bg-bambu-green/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white"
+              >
+                {t('connectedApps.authorize.allow')}
+              </button>
+            </div>
+          </>
+        )}
+      </div>
+    </div>
+  );
+}

+ 19 - 1
frontend/src/pages/SettingsPage.tsx

@@ -22,6 +22,7 @@ import type { APIKey, AppSettings, AppSettingsUpdate, PrinterHASensor, LocationH
 import { Card, CardContent, CardDensityProvider, CardHeader } from '../components/Card';
 import { SlicerPipelinesPanel } from '../components/SlicerPipelinesPanel';
 import { CameraTokensSection } from './CameraTokensPage';
+import { ConnectedAppsSection } from '../components/ConnectedAppsSection';
 import { StreamOverlayBuilder } from '../components/StreamOverlayBuilder';
 import { Collapsible } from '../components/Collapsible';
 import { CopyButton } from '../components/CopyButton';
@@ -59,7 +60,7 @@ import { availableLanguages } from '../i18n';
 import { useToast } from '../contexts/ToastContext';
 import { useTheme, type ThemeStyle, type DarkBackground, type LightBackground, type ThemeAccent } from '../contexts/ThemeContext';
 import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
-import { Gauge, Palette } from 'lucide-react';
+import { Gauge, Link2, Palette } from 'lucide-react';
 import { registerSettingsSearch, getSettingsSearchEntries } from '../lib/settingsSearch';
 import type { UsersSubTab } from '../lib/settingsSearch';
 import { availableEngines, hasEngineChoice, resolveEngine, type SliceEngineId } from '../lib/sliceEngines';
@@ -102,6 +103,7 @@ registerSettingsSearch({ labelKey: 'settings.prometheusMetrics', tab: 'network',
 registerSettingsSearch({ labelKey: 'settings.createNewApiKey', tab: 'apikeys', keywords: 'api key create permission scope', anchor: 'card-createapi' });
 registerSettingsSearch({ labelKey: 'settings.webhookEndpoints', tab: 'apikeys', keywords: 'webhook endpoint post http', anchor: 'card-webhooks' });
 registerSettingsSearch({ labelKey: 'settings.apiBrowser', tab: 'apikeys', keywords: 'api browser endpoint documentation test', anchor: 'card-apibrowser' });
+registerSettingsSearch({ labelKey: 'connectedApps.title', tab: 'apikeys', keywords: 'connected app sign in single sign-on sso oauth login orders', anchor: 'card-connected-apps' });
 registerSettingsSearch({ labelKey: 'cameraTokens.title', tab: 'apikeys', keywords: 'camera token long-lived home assistant frigate kiosk stream', anchor: 'card-camera-tokens' });
 registerSettingsSearch({ labelKey: 'settings.tabs.virtualPrinter', tab: 'virtual-printer', keywords: 'virtual printer proxy archive slicer bambustudio orcaslicer ip bind', anchor: 'card-vp' });
 registerSettingsSearch({ labelKey: 'settings.tabs.spoolbuddy', tab: 'spoolbuddy', keywords: 'spoolbuddy device scale nfc rfid kiosk unregister', anchor: 'card-spoolbuddy' });
@@ -4787,6 +4789,22 @@ export function SettingsPage() {
             </Card>
             </>}
 
+            {/* Connected apps: "Sign in with Bambuddy" for external applications.
+                Admin-only, like the settings it sits between. */}
+            {hasPermission('settings:update') && (
+              <Card className="mt-6">
+                <CardHeader>
+                  <h3 className="text-base font-semibold text-white flex items-center gap-2" id="card-connected-apps">
+                    <Link2 className="w-4 h-4 text-bambu-green" />
+                    {t('connectedApps.title')}
+                  </h3>
+                </CardHeader>
+                <CardContent>
+                  <ConnectedAppsSection />
+                </CardContent>
+              </Card>
+            )}
+
             {/* Long-lived camera-stream tokens (#1108) */}
             <Card className="mt-6">
               <CardHeader>

Plik diff jest za duży
+ 0 - 1
static/assets/PdfPreviewModal-DcCIp96X.js


Plik diff jest za duży
+ 0 - 0
static/assets/SpreadsheetPreviewModal-DkGzwxhn.js


Plik diff jest za duży
+ 0 - 1
static/assets/index-BCr8craX.css


Plik diff jest za duży
+ 1 - 1
static/assets/index-VsfgUmwa.js


Plik diff jest za duży
+ 1 - 0
static/assets/index-ldjSdDIZ.css


Plik diff jest za duży
+ 0 - 0
static/assets/pdf-CFOqAiZi.js


+ 2 - 2
static/index.html

@@ -26,9 +26,9 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-wE5DdboK.js"></script>
+    <script type="module" crossorigin src="/assets/index-VsfgUmwa.js"></script>
     <link rel="modulepreload" crossorigin href="/assets/chunk-aKtaBQYM.js">
-    <link rel="stylesheet" crossorigin href="/assets/index-BCr8craX.css">
+    <link rel="stylesheet" crossorigin href="/assets/index-ldjSdDIZ.css">
   </head>
   <body>
     <div id="root"></div>

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików