# NousResearch/hermes-agent · Dashboard 500 when basic auth is sole provider

## Open now

- Nothing open on GitHub.

A bug where the dashboard crashes with HTTP 500 on login when BasicAuth is the only configured provider, because the auto-SSO redirect incorrectly routes password-only providers through an OAuth-only code path. Multiple issues and fix attempts address this same problem.

## TL;DR

- 48 member(s): 0 open issue(s), 25 closed issue(s), 0 open PR(s), 1 merged PR(s), 22 PR(s) closed without merging.
- A fix was merged to main in PR #61330. Verify it addresses your case.
- **Recommended:** read PR #61330 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.07`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #55130: Dashboard 500s on every page load when basic (password) auth is the only provider — auto-SSO calls start_login() on a password-only provider
- #55498: Dashboard: BasicAuthProvider crashes with NotImplementedError on /auth/login
- #55985: Dashboard logout crashes container via NotImplementedError in BasicAuthProvider
- #56067: Dashboard 500 on password-only auth provider: _auto_sso_response redirects to /auth/login instead of /login
- #56130: Dashboard returns HTTP 500 on non-loopback bind with basic auth (BasicAuthProvider auto-redirect crash)
- #57211: Dashboard 500s on login when basic_auth is the only provider (auto-SSO calls start_login() on a password provider)
- #57220: Dashboard returns HTTP 500 on first visit when basic is the only auth provider (auto-SSO redirects into OAuth-only route)
- #57294: fix(dashboard): password-only providers crash on /auth/login route
- #57482: LAN/local dashboard access requires password after June 2026 auth hardening
- #57868: Dashboard basic auth (password-only provider) crashes with 500 on first page load
- #58020: Dashboard /auth/login?provider=basic returns 500 — BasicAuthProvider.start_login() raises NotImplementedError
- #58166: fix(dashboard-auth): GET /auth/login?provider=basic returns HTTP 500 (NotImplementedError in BasicAuthProvider.start_login)
- #58237: \[Bug\]: Dashboard auto-SSO redirect 500s when only basic auth provider is configured
- #58626: Dashboard: BasicAuthProvider triggers NotImplementedError when binding to 0.0.0.0
- #58810: Dashboard: 500 on unauthenticated \`/\` when the only auth provider is password-only (auto-SSO redirects into OAuth start_login)
- #58921: 500 Internal Server Error when BasicAuthProvider is the only auth provider (auto-SSO crashes on password-only provider)
- #59748: v0.18.0: Dashboard /auth/login crashes when BasicAuth is sole session provider
- #60105: Dashboard returns 500 on / when the only registered auth provider is password-only (BasicAuthProvider)
- #60660: Dashboard: root '/' returns 500 when sole auth provider is password-only (basic)
- #61341: dashboard auth: /auth/login?provider=basic returns 500 for password-only providers
- #62463: Dashboard auto-SSO redirect crashes (500) when the sole configured provider is BasicAuthProvider
- #62782: Dashboard Basic Auth login page triggers OAuth redirect instead of rendering password form (500 Internal Server Error)
- #64424: Dashboard auto-SSO redirect breaks for basic_auth (password-only) providers
- #65327: BasicAuthProvider 缺少 supports_session=False 导致 Dashboard 自动 SSO 跳转时 500 错误
- #67460: Dashboard basic auth: unauthenticated visit 500s (single-provider redirect goes to the OAuth kick-off route)

## Prior & competing PRs

- **#55133: fix(dashboard): skip auto-SSO for password-only auth providers (500 on basic-auth-only dashboard)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#55235: fix(dashboard): skip auto-SSO redirect for password-only providers (#55130)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54887`, `#54887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#55330: fix(cli): skip auto-SSO redirect for password-only auth providers**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`
- **#55993: fix(dashboard): handle NotImplementedError on logout for password-only providers (#55985)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f5`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#56082: fix: skip auto-SSO redirect for password-only auth providers**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#57214: fix(dashboard-auth): skip auto-SSO for password-only provider (500 on login)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#57513: fix(gateway): use getattr for __cause__ in _is_transient_network_error**. closed without merge · fix tier `suggested`
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `#57294`, `#57298`, `#57299`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#58247: fix(dashboard-auth): skip password-only providers in auto-SSO redirect (#58237)**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#60978: fix(#55985): guard start_login() call — BasicAuthProvider has no OAuth redirect flow**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#61330: fix(dashboard): support mobile/hosted OpenAI OAuth login (salvage #61281)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `hermes_cli/dashboard_auth/middleware.py`, `hermes_cli/dashboard_auth/routes.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_dashboard_auth_password_login.py`, `tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py`, `tests/hermes_cli/test_web_oauth_dispatch.py`, `web/src/components/OAuthLoginModal.tsx`, `web/src/i18n/af.ts`, `web/src/i18n/de.ts`, `web/src/i18n/en.ts`, `web/src/i18n/es.ts`, `web/src/i18n/fr.ts`, `web/src/i18n/ga.ts`, `web/src/i18n/hu.ts`, `web/src/i18n/it.ts`, `web/src/i18n/ja.ts`, `web/src/i18n/ko.ts`, `web/src/i18n/pt.ts`, `web/src/i18n/ru.ts`, `web/src/i18n/tr.ts` (+8 more)
  - fix hunks:
  - `hermes_cli/dashboard_auth/middleware.py`

    ```diff
    @@ -150,6 +150,8 @@ def _auto_sso_response(request: Request) -> Response | None:
           * exactly ONE interactive provider is registered — with two or more we
             can't pick for the user, so the ``/login`` chooser must render; with
             zero there's nothing to redirect to;
    +      * that provider is OAuth-style, not a password form provider. Password
    +        providers must render ``/login`` so the user can enter credentials;
           * the one-shot loop-guard marker is ABSENT. Its presence means we
             already bounced to the portal once and came back still
             unauthenticated (no portal session) — auto-redirecting again would
    ```

    ```diff
    @@ -185,6 +187,9 @@ def _auto_sso_response(request: Request) -> Response | None:
         from hermes_cli.dashboard_auth.prefix import prefix_from_request
     
         provider = providers[0]
    +    if getattr(provider, "supports_password", False):
    +        return None
    +
         prefix = prefix_from_request(request)
         next_param = _safe_next_target(request)
         from urllib.parse import quote
    ```

    ```diff
    @@ -458,4 +463,3 @@ def _attempt_refresh(request: Request, *, refresh_token):
             if new_session is not None:
                 return new_session, provider.name
         return None
    -
    ```
  - `hermes_cli/dashboard_auth/routes.py`

    ```diff
    @@ -192,6 +192,14 @@ async def auth_login(request: Request, provider: str, next: str = ""):
                 status_code=404,
                 detail=f"Provider does not support interactive login: {provider!r}",
             )
    +    if getattr(p, "supports_password", False):
    +        from urllib.parse import quote
    +
    +        safe_next = _validate_post_login_target(next)
    +        login_url = f"{_prefix(request)}/login"
    +        if safe_next:
    +            login_url = f"{login_url}?next={quote(safe_next, safe='')}"
    +        return RedirectResponse(url=login_url, status_code=302)
     
         try:
             ls = p.start_login(redirect_uri=_redirect_uri(request))
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -9087,6 +9087,54 @@ def _xai_device_poller(session_id: str) -> None:
                 sess["error_message"] = str(e)
     
     
    +def _http_response_error_detail(resp: Any) -> str:
    +    """Best-effort extraction of a short provider error detail."""
    +    try:
    +        payload = resp.json()
    +    except Exception:
    +        payload = None
    +    if isinstance(payload, dict):
    +        error = payload.get("error")
    +        if isinstance(error, dict):
    +            parts = [
    +                str(error.get(key, "")).strip()
    +                for key in ("message", "error_description", "code", "type")
    +                if str(error.get(key, "")).strip()
    +            ]
    +            if parts:
    +                return ": ".join(parts)
    +        if isinstance(error, str) and error.strip():
    +            return error.strip()
    +        for key in ("detail", "message", "error_description"):
    +            value = payload.get(key)
    +            if isinstance(value, str) and value.strip():
    +                return value.strip()
    +    text = str(getattr(resp, "text", "") or "").strip()
    +    return text[:500]
    +
    +
    +def _codex_device_code_start_error(resp: Any) -> str:
    +    """Dashboard-facing OpenAI Codex device-code start failure."""
    +    status = getattr(resp, "status_code", "unknown")
    +    detail = _http_response_error_detail(resp)
    +    lower = detail.lower()
    +    if "device" in lower and ("authori" in lower or "enable" in lower):
    +        message = (
    +            "OpenAI rejected the device-code login request. Your OpenAI "
    +            "account may need device-code authorization enabled before Hermes "
    +            "can start this dashboard login. Enable device-code authorization "
    +            "in OpenAI, then return here and click Login again."
    +        )
    +    else:
    +        message = (
    +            "OpenAI rejected the device-code login request. Please try Login "
    +            "again from the dashboard after checking your OpenAI account settings."
    +        )
    +    if detail:
    +        return f"{message} (HTTP {status}: {detail})"
    +    return f"{message} (HTTP {status})"
    +
    +
     def _codex_full_login_worker(session_id: str) -> None:
         """Run the complete OpenAI Codex device-code flow.
     
    ```

    ```diff
    @@ -9119,7 +9167,7 @@ def _codex_full_login_worker(session_id: str) -> None:
                     headers={"Content-Type": "application/json"},
                 )
             if resp.status_code != 200:
    -            raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}")
    +            raise RuntimeError(_codex_device_code_start_error(resp))
             device_data = resp.json()
             user_code = device_data.get("user_code", "")
             device_auth_id = device_data.get("device_auth_id", "")
    ```
  - `tests/hermes_cli/test_dashboard_auth_password_login.py`

    ```diff
    @@ -198,6 +198,24 @@ def test_providers_endpoint_reports_supports_password(self, gated_app):
             prov = {p["name"]: p for p in resp.json()["providers"]}
             assert prov["testpw"]["supports_password"] is True
     
    +    def test_password_provider_html_redirects_to_login_form(self, gated_app):
    +        resp = gated_app.get("/", follow_redirects=False)
    +        assert resp.status_code == 302
    +        assert resp.headers["location"] == "/login?next=%2F"
    +
    +        login = gated_app.get(resp.headers["location"])
    +        assert login.status_code == 200
    +        assert '<form class="provider-form" data-provider="testpw"' in login.text
    +        assert "/auth/password-login" in login.text
    +
    +    def test_password_provider_auth_login_redirects_to_login_form(self, gated_app):
    +        resp = gated_app.get(
    +            "/auth/login?provider=testpw&next=%2F",
    +            follow_redirects=False,
    +        )
    +        assert resp.status_code == 302
    +        assert resp.headers["location"] == "/login?next=%2F"
    +
         def test_oauth_provider_reports_false(self):
             clear_providers()
             register_provider(StubAuthProvider())
    ```
  - `tests/hermes_cli/test_dashboard_oauth_endpoints_server_gate.py`

    ```diff
    @@ -0,0 +1,69 @@
    +"""Regression guard for PR #61281 (mobile/hosted dashboard OAuth).
    +
    +The PR removed the *client-side* ``X-Hermes-Session-Token`` requirement from
    +the dashboard OAuth mutation calls (``web/src/lib/api.ts``) so that
    +cookie-authenticated hosted/mobile sessions can start provider logins. The
    +safety of that change rests entirely on the *server* still gating those
    +endpoints: in gated mode the ``gated_auth_middleware`` verifies the session
    +cookie before the handler runs, and ``_require_token`` defers to it.
    +
    +These tests pin that server-side gate for the exact endpoints whose
    +client-side token gate was removed. Without them, a future change that
    +re-broke ``_require_token``'s gated-mode branch (e.g. letting it fall through
    +without a session) would still pass the PR's ``api.test.ts`` suite, because
    +those tests only mock ``fetch`` and never touch the server.
    +"""
    +
    +import pytest
    +from fastapi.testclient import TestClient
    +
    +from hermes_cli import web_server
    +from hermes_cli.dashboard_auth import clear_providers, register_provider
    +from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
    +
    +
    [redacted:email]
    +def gated_app():
    +    """A gated (``auth_required``) dashboard with no session cookie set."""
    +    clear_providers()
    +    register_provider(StubAuthProvider())
    +    prev_host = getattr(web_server.app.state, "bound_host", None)
    +    prev_port = getattr(web_server.app.state, "bound_port", None)
    +    prev_required = getattr(web_server.app.state, "auth_required", None)
    +    web_server.app.state.bound_host = "fly-app.fly.dev"
    +    web_server.app.state.bound_port = 443
    +    web_server.app.state.auth_required = True
    +    client = TestClient(web_server.app, base_url="https://fly-app.fly.dev")
    +    yield client
    +    clear_providers()
    +    web_server.app.state.bound_host = prev_host
    +    web_server.app.state.bound_port = prev_port
    +    web_server.app.state.auth_required = prev_required
    +
    +
    +class TestOAuthMutationEndpointsGatedWithoutCookie:
    +    """No cookie in gated mode -> 401 on every endpoint whose client-side
    +    session-token gate PR #61281 removed."""
    +
    +    def test_env_reveal_requires_cookie(self, gated_app):
    +        r = gated_app.post("/api/env/reveal", json={"key": "OPENAI_API_KEY"})
    +        assert r.status_code == 401
    +
    +    def test_oauth_disconnect_requires_cookie(self, gated_app):
    +        r = gated_app.delete("/api/providers/oauth/anthropic")
    +        assert r.status_code == 401
    +
    +    def test_oauth_start_requires_cookie(self, gated_app):
    +        r = gated_app.post("/api/providers/oauth/anthropic/start", json={})
    +        assert r.status_code == 401
    +
    +    def test_oauth_submit_requires_cookie(self, gated_app):
    +        r = gated_app.post(
    +            "/api/providers/oauth/anthropic/submit",
    +            json={"session_id": "sid", "code": "abc"},
    +        )
    +        assert r.status_code == 401
    +
    +    def test_oauth_cancel_session_requires_cookie(self, gated_app):
    +        r = gated_app.delete("/api/providers/oauth/sessions/sid")
    +        assert r.status_code == 401
    ```
  - `tests/hermes_cli/test_web_oauth_dispatch.py`

    ```diff
    @@ -340,6 +340,56 @@ def post(self, url, **kwargs):
             ws._oauth_sessions.pop(sid, None)
     
     
    +def test_codex_dashboard_start_rewords_device_authorization_error(monkeypatch):
    +    from hermes_cli import web_server as ws
    +
    +    before_sessions = set(ws._oauth_sessions)
    +
    +    class _Resp:
    +        status_code = 400
    +        text = "Enable device code authorization"
    +
    +        def json(self):
    +            return {
    +                "error": {
    +                    "message": "Enable device code authorization",
    +                    "code": "device_authorization_not_enabled",
    +                }
    +            }
    +
    +    class _Client:
    +        def __init__(self, *args, **kwargs):
    +            pass
    +
    +        def __enter__(self):
    +            return self
    +
    +        def __exit__(self, *args):
    +            return False
    +
    +        def post(self, url, **kwargs):
    +            assert url.endswith("/deviceauth/usercode")
    +            return _Resp()
    +
    +    monkeypatch.setattr(httpx, "Client", _Client)
    +
    +    try:
    +        resp = client.post(
    +            "/api/providers/oauth/openai-codex/start",
    +            headers=HEADERS,
    +        )
    +
    +        assert resp.status_code == 500
    +        detail = resp.json()["detail"]
    +        assert "OpenAI rejected the device-code login request" in detail
    +        assert "Enable device-code authorization in OpenAI" in detail
    +        assert "click Login again" in detail
    +        assert "hermes auth" not in detail
    +    finally:
    +        for sid in set(ws._oauth_sessions) - before_sessions:
    +            ws._oauth_sessions.pop(sid, None)
    +
    +
     def test_nous_dashboard_poller_preserves_effective_scope_when_token_omits_scope(monkeypatch):
         from hermes_cli import auth as auth_mod
         from hermes_cli import web_server as ws
    ```
  - `web/src/components/OAuthLoginModal.tsx`

    ```diff
    @@ -1,10 +1,10 @@
     import { useEffect, useRef, useState } from "react";
    -import { ExternalLink, X, Check } from "lucide-react";
    +import { ExternalLink, X, Check, Copy } from "lucide-react";
     import { Button } from "@nous-research/ui/ui/components/button";
    -import { CopyButton } from "@nous-research/ui/ui/components/command-block";
     import { Spinner } from "@nous-research/ui/ui/components/spinner";
     import { H2 } from "@nous-research/ui/ui/components/typography/h2";
     import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
    +import { copyTextToClipboard } from "@/lib/clipboard";
     import { Input } from "@nous-research/ui/ui/components/input";
     import { useI18n } from "@/i18n";
     import { cn, themedBody } from "@/lib/utils";
    ```

    ```diff
    @@ -30,9 +30,13 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
       const [start, setStart] = useState<OAuthStartResponse | null>(null);
       const [pkceCode, setPkceCode] = useState("");
       const [errorMsg, setErrorMsg] = useState<string | null>(null);
    +  const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">(
    +    "idle",
    +  );
       const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
       const isMounted = useRef(true);
       const pollTimer = useRef<number | null>(null);
    +  const copyResetTimer = useRef<number | null>(null);
       const { t } = useI18n();
     
       // Initiate flow on mount
    ```

    ```diff
    @@ -59,6 +63,8 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
         return () => {
           isMounted.current = false;
           if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
    +      if (copyResetTimer.current !== null)
    +        window.clearTimeout(copyResetTimer.current);
         };
         // eslint-disable-next-line react-hooks/exhaustive-deps
       }, []);
    ```

    ```diff
    @@ -162,6 +168,24 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
         return `${m}:${String(r).padStart(2, "0")}`;
       };
     
    +  const handleCopyDeviceCode = async (code: string) => {
    +    if (copyResetTimer.current !== null) {
    +      window.clearTimeout(copyResetTimer.current);
    +      copyResetTimer.current = null;
    +    }
    +    const copied = await copyTextToClipboard(code);
    +    if (!isMounted.current) return;
    +    setCopyStatus(copied ? "copied" : "failed");
    +    copyResetTimer.current = window.setTimeout(() => {
    +      if (isMounted.current) setCopyStatus("idle");
    +      copyResetTimer.current = null;
    +    }, 2000);
    +  };
    +
    +  const deviceCode = start?.flow === "device_code" ? start.user_code : "";
    +  const verificationUrl =
    +    start?.flow === "device_code" ? start.verification_url : "";
    +
       return (
         <div
           className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 p-4"
    ```

    ```diff
    @@ -262,35 +286,32 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
                   </p>
                   <div className="flex items-center justify-between gap-2 border border-border bg-secondary/30 p-4">
                     <code className="font-mono-ui text-2xl tracking-widest text-foreground">
    -                  {
    -                    (
    -                      start as Extract<
    -                        OAuthStartResponse,
    -                        { flow: "device_code" }
    -                      >
    -                    ).user_code
    -                  }
    +                  {deviceCode}
                     </code>
    -                <CopyButton
    -                  text={
    -                    (
    -                      start as Extract<
    -                        OAuthStartResponse,
    -                        { flow: "device_code" }
    -                      >
    -                    ).user_code
    +                <Button
    +                  size="sm"
    +                  outlined
    +                  className="shrink-0 uppercase"
    +                  onClick={() => void handleCopyDeviceCode(deviceCode)}
    +                  prefix={
    +                    copyStatus === "copied" ? (
    +                      <Check className="h-4 w-4" />
    +                    ) : (
    +                      <Copy className="h-4 w-4" />
    +                    )
                       }
    -                />
    +                  aria-label={t.oauth.copyCode}
    +                >
    +                  {copyStatus === "copied" ? t.oauth.copied : t.oauth.copyCode}
    +                </Button>
                   </div>
    +              {copyStatus === "failed" && (
    +                <p className="text-xs text-destructive">
    +                  {t.oauth.copyFailed}
    +                </p>
    +              )}
                   <a
    -                href={
    -                  (
    -                    start as Extract<
    -                      OAuthStartResponse,
    -                      { flow: "device_code" }
    -                    >
    -                  ).verification_url
    -                }
    +                href={verificationUrl}
                     target="_blank"
                     rel="noopener noreferrer"
                     className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
    ```
  - `web/src/i18n/af.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const af: Translations = {
       oauth: {
         title: "Verskaffer-aanmeldings (OAuth)",
         providerLogins: "Verskaffer-aanmeldings (OAuth)",
    -    description: "{connected} van {total} OAuth-verskaffers gekoppel. Aanmeldvloei loop tans via die CLI; klik Kopieer opdrag en plak in 'n terminaal om op te stel.",
    +    description:
    +      "{connected} van {total} OAuth-verskaffers gekoppel. Gebruik Meld aan vir vloeie wat die kontroleskerm ondersteun; CLI-opdragte bly beskikbaar vir eksterne of terugval-opstelling.",
         connected: "Gekoppel",
         expired: "Verval",
    -    notConnected: "Nie gekoppel nie. Voer {command} uit in 'n terminaal.",
    +    notConnected: "Nie gekoppel nie. Gebruik Meld aan indien beskikbaar, of voer {command} uit in 'n terminaal.",
         runInTerminal: "in 'n terminaal.",
         noProviders: "Geen OAuth-bekwame verskaffers opgespoor nie.",
         login: "Meld aan",
         disconnect: "Ontkoppel",
         managedExternally: "Ekstern bestuur",
         copied: "Gekopieer ✓",
    +    copyCode: "Kopieer kode",
    +    copyFailed: "Kon nie outomaties kopieer nie. Kies die kode en kopieer dit met die hand.",
         cli: "Kopieer",
         copyCliCommand: "Kopieer CLI-opdrag (vir ekstern / terugval)",
         connect: "Koppel",
    ```
  - `web/src/i18n/de.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const de: Translations = {
       oauth: {
         title: "Anbieter-Logins (OAuth)",
         providerLogins: "Anbieter-Logins (OAuth)",
    -    description: "{connected} von {total} OAuth-Anbietern verbunden. Login-Abläufe laufen derzeit über die CLI; klicke auf Befehl kopieren und füge ihn in ein Terminal ein, um einzurichten.",
    +    description:
    +      "{connected} von {total} OAuth-Anbietern verbunden. Nutze Anmelden für vom Dashboard unterstützte Abläufe; CLI-Befehle bleiben für externe oder Fallback-Einrichtung verfügbar.",
         connected: "Verbunden",
         expired: "Abgelaufen",
    -    notConnected: "Nicht verbunden. Führe {command} in einem Terminal aus.",
    +    notConnected: "Nicht verbunden. Nutze Anmelden, falls verfügbar, oder führe {command} in einem Terminal aus.",
         runInTerminal: "in einem Terminal.",
         noProviders: "Keine OAuth-fähigen Anbieter erkannt.",
         login: "Anmelden",
         disconnect: "Trennen",
         managedExternally: "Extern verwaltet",
         copied: "Kopiert ✓",
    +    copyCode: "Code kopieren",
    +    copyFailed: "Automatisches Kopieren fehlgeschlagen. Markiere den Code und kopiere ihn manuell.",
         cli: "Kopieren",
         copyCliCommand: "CLI-Befehl kopieren (für extern / Fallback)",
         connect: "Verbinden",
    ```
  - `web/src/i18n/en.ts`

    ```diff
    @@ -501,16 +501,19 @@ export const en: Translations = {
       oauth: {
         title: "Provider Logins (OAuth)",
         providerLogins: "Provider Logins (OAuth)",
    -    description: "{connected} of {total} OAuth providers connected. Login flows currently run via the CLI; click Copy command and paste into a terminal to set up.",
    +    description:
    +      "{connected} of {total} OAuth providers connected. Use Login for dashboard-supported flows; CLI commands remain available for external or fallback setup.",
         connected: "Connected",
         expired: "Expired",
    -    notConnected: "Not connected. Run {command} in a terminal.",
    +    notConnected: "Not connected. Use Login when available, or run {command} in a terminal.",
         runInTerminal: "in a terminal.",
         noProviders: "No OAuth-capable providers detected.",
         login: "Login",
         disconnect: "Disconnect",
         managedExternally: "Managed externally",
         copied: "Copied ✓",
    +    copyCode: "Copy code",
    +    copyFailed: "Could not copy automatically. Select the code and copy it manually.",
         cli: "Copy",
         copyCliCommand: "Copy CLI command (for external / fallback)",
         connect: "Connect",
    ```
  - `web/src/i18n/es.ts`

    ```diff
    @@ -447,16 +447,19 @@ export const es: Translations = {
       oauth: {
         title: "Inicios de sesión de proveedores (OAuth)",
         providerLogins: "Inicios de sesión de proveedores (OAuth)",
    -    description: "{connected} de {total} proveedores OAuth conectados. Los flujos de inicio de sesión actualmente se ejecutan a través de la CLI; haz clic en Copiar comando y pégalo en una terminal para configurar.",
    +    description:
    +      "{connected} de {total} proveedores OAuth conectados. Usa Iniciar sesión para los flujos compatibles con el panel; los comandos CLI siguen disponibles para configuración externa o de respaldo.",
         connected: "Conectado",
         expired: "Caducado",
    -    notConnected: "No conectado. Ejecuta {command} en una terminal.",
    +    notConnected: "No conectado. Usa Iniciar sesión si está disponible, o ejecuta {command} en una terminal.",
         runInTerminal: "en una terminal.",
         noProviders: "No se han detectado proveedores compatibles con OAuth.",
         login: "Iniciar sesión",
         disconnect: "Desconectar",
         managedExternally: "Gestionado externamente",
         copied: "Copiado ✓",
    +    copyCode: "Copiar código",
    +    copyFailed: "No se pudo copiar automáticamente. Selecciona el código y cópialo manualmente.",
         cli: "Copiar",
         copyCliCommand: "Copiar comando CLI (para externo / alternativa)",
         connect: "Conectar",
    ```
  - `web/src/i18n/fr.ts`

    ```diff
    @@ -447,16 +447,19 @@ export const fr: Translations = {
       oauth: {
         title: "Connexions fournisseurs (OAuth)",
         providerLogins: "Connexions fournisseurs (OAuth)",
    -    description: "{connected} sur {total} fournisseurs OAuth connectés. Les flux de connexion s'exécutent actuellement via le CLI ; cliquez sur Copier la commande et collez-la dans un terminal pour configurer.",
    +    description:
    +      "{connected} sur {total} fournisseurs OAuth connectés. Utilisez Connexion pour les flux pris en charge par le tableau de bord ; les commandes CLI restent disponibles pour une configuration externe ou de secours.",
         connected: "Connecté",
         expired: "Expiré",
    -    notConnected: "Non connecté. Exécutez {command} dans un terminal.",
    +    notConnected: "Non connecté. Utilisez Connexion si disponible, ou exécutez {command} dans un terminal.",
         runInTerminal: "dans un terminal.",
         noProviders: "Aucun fournisseur compatible OAuth détecté.",
         login: "Connexion",
         disconnect: "Déconnecter",
         managedExternally: "Géré en externe",
         copied: "Copié ✓",
    +    copyCode: "Copier le code",
    +    copyFailed: "Copie automatique impossible. Sélectionnez le code et copiez-le manuellement.",
         cli: "Copier",
         copyCliCommand: "Copier la commande CLI (pour externe / repli)",
         connect: "Connecter",
    ```
  - `web/src/i18n/ga.ts`

    ```diff
    @@ -454,16 +454,19 @@ export const ga: Translations = {
       oauth: {
         title: "Logálacha isteach soláthraí (OAuth)",
         providerLogins: "Logálacha isteach soláthraí (OAuth)",
    -    description: "{connected} as {total} soláthraí OAuth ceangailte. Reáchtáiltear sreabha logála isteach faoi láthair tríd an CLI; cliceáil Cóipeáil ordú agus greamaigh i dteirminéal chun é a shocrú.",
    +    description:
    +      "{connected} as {total} soláthraí OAuth ceangailte. Úsáid Logáil isteach le haghaidh sreabha a dtacaíonn an deais leo; tá orduithe CLI ar fáil i gcónaí do shocrú seachtrach nó cúltaca.",
         connected: "Ceangailte",
         expired: "As feidhm",
    -    notConnected: "Gan cheangal. Rith {command} i dteirminéal.",
    +    notConnected: "Gan cheangal. Úsáid Logáil isteach má tá sé ar fáil, nó rith {command} i dteirminéal.",
         runInTerminal: "i dteirminéal.",
         noProviders: "Níor aimsíodh soláthraithe a thacaíonn le OAuth.",
         login: "Logáil isteach",
         disconnect: "Dícheangail",
         managedExternally: "Bainistithe go seachtrach",
         copied: "Cóipeáilte ✓",
    +    copyCode: "Cóipeáil an cód",
    +    copyFailed: "Níorbh fhéidir cóipeáil go huathoibríoch. Roghnaigh an cód agus cóipeáil de láimh é.",
         cli: "Cóipeáil",
         copyCliCommand: "Cóipeáil ordú CLI (le haghaidh úsáide seachtraí / cúltaca)",
         connect: "Ceangail",
    ```
  - `web/src/i18n/hu.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const hu: Translations = {
       oauth: {
         title: "Szolgáltatói bejelentkezések (OAuth)",
         providerLogins: "Szolgáltatói bejelentkezések (OAuth)",
    -    description: "{connected} / {total} OAuth-szolgáltató csatlakoztatva. A bejelentkezési folyamat jelenleg a CLI-n keresztül fut; kattintson a Parancs másolása gombra, és illessze be egy terminálba a beállításhoz.",
    +    description:
    +      "{connected} / {total} OAuth-szolgáltató csatlakoztatva. Használja a Bejelentkezés gombot az irányítópult által támogatott folyamatokhoz; a CLI-parancsok továbbra is elérhetők külső vagy tartalék beállításhoz.",
         connected: "Csatlakoztatva",
         expired: "Lejárt",
    -    notConnected: "Nincs csatlakoztatva. Futtassa a {command} parancsot egy terminálban.",
    +    notConnected: "Nincs csatlakoztatva. Használja a Bejelentkezés gombot, ha elérhető, vagy futtassa a {command} parancsot egy terminálban.",
         runInTerminal: "egy terminálban.",
         noProviders: "Nem észlelhető OAuth-képes szolgáltató.",
         login: "Bejelentkezés",
         disconnect: "Lecsatlakozás",
         managedExternally: "Külsőleg kezelt",
         copied: "Másolva ✓",
    +    copyCode: "Kód másolása",
    +    copyFailed: "Nem sikerült automatikusan másolni. Jelölje ki a kódot, és másolja kézzel.",
         cli: "Másolás",
         copyCliCommand: "CLI-parancs másolása (külső / tartalék)",
         connect: "Csatlakozás",
    ```
  - `web/src/i18n/it.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const it: Translations = {
       oauth: {
         title: "Accessi provider (OAuth)",
         providerLogins: "Accessi provider (OAuth)",
    -    description: "{connected} di {total} provider OAuth connessi. I flussi di accesso vengono attualmente eseguiti tramite la CLI; clicca Copia comando e incolla in un terminale per configurare.",
    +    description:
    +      "{connected} di {total} provider OAuth connessi. Usa Accedi per i flussi supportati dalla dashboard; i comandi CLI restano disponibili per configurazioni esterne o di riserva.",
         connected: "Connesso",
         expired: "Scaduto",
    -    notConnected: "Non connesso. Esegui {command} in un terminale.",
    +    notConnected: "Non connesso. Usa Accedi se disponibile, oppure esegui {command} in un terminale.",
         runInTerminal: "in un terminale.",
         noProviders: "Nessun provider compatibile con OAuth rilevato.",
         login: "Accedi",
         disconnect: "Disconnetti",
         managedExternally: "Gestito esternamente",
         copied: "Copiato ✓",
    +    copyCode: "Copia codice",
    +    copyFailed: "Impossibile copiare automaticamente. Seleziona il codice e copialo manualmente.",
         cli: "Copia",
         copyCliCommand: "Copia comando CLI (per uso esterno / fallback)",
         connect: "Connetti",
    ```
  - `web/src/i18n/ja.ts`

    ```diff
    @@ -445,16 +445,19 @@ export const ja: Translations = {
       oauth: {
         title: "プロバイダーログイン (OAuth)",
         providerLogins: "プロバイダーログイン (OAuth)",
    -    description: "{connected} / {total} OAuth プロバイダーが接続されています。ログインフローは現在 CLI 経由で実行されます。「コマンドをコピー」をクリックして、ターミナルに貼り付けてセットアップしてください。",
    +    description:
    +      "{connected} / {total} OAuth プロバイダーが接続されています。ダッシュボード対応のフローには「ログイン」を使用してください。外部またはフォールバック用のセットアップには引き続き CLI コマンドを利用できます。",
         connected: "接続済み",
         expired: "期限切れ",
    -    notConnected: "未接続です。ターミナルで {command} を実行してください。",
    +    notConnected: "未接続です。可能な場合は「ログイン」を使用するか、ターミナルで {command} を実行してください。",
         runInTerminal: "ターミナルで実行してください。",
         noProviders: "OAuth 対応プロバイダーは検出されませんでした。",
         login: "ログイン",
         disconnect: "切断",
         managedExternally: "外部で管理",
         copied: "コピーしました ✓",
    +    copyCode: "コードをコピー",
    +    copyFailed: "自動でコピーできませんでした。コードを選択して手動でコピーしてください。",
         cli: "コピー",
         copyCliCommand: "CLI コマンドをコピー (外部 / フォールバック用)",
         connect: "接続",
    ```
  - `web/src/i18n/ko.ts`

    ```diff
    @@ -445,16 +445,19 @@ export const ko: Translations = {
       oauth: {
         title: "제공자 로그인 (OAuth)",
         providerLogins: "제공자 로그인 (OAuth)",
    -    description: "{connected}/{total} OAuth 제공자가 연결되었습니다. 로그인 흐름은 현재 CLI를 통해 실행됩니다. 명령 복사를 클릭하고 터미널에 붙여넣어 설정하세요.",
    +    description:
    +      "{connected}/{total} OAuth 제공자가 연결되었습니다. 대시보드에서 지원되는 흐름에는 로그인을 사용하세요. 외부 또는 대체 설정에는 CLI 명령을 계속 사용할 수 있습니다.",
         connected: "연결됨",
         expired: "만료됨",
    -    notConnected: "연결되지 않음. 터미널에서 {command}을(를) 실행하세요.",
    +    notConnected: "연결되지 않음. 가능하면 로그인을 사용하거나 터미널에서 {command}을(를) 실행하세요.",
         runInTerminal: "터미널에서.",
         noProviders: "OAuth를 지원하는 제공자가 감지되지 않았습니다.",
         login: "로그인",
         disconnect: "연결 해제",
         managedExternally: "외부에서 관리됨",
         copied: "복사됨 ✓",
    +    copyCode: "코드 복사",
    +    copyFailed: "자동으로 복사할 수 없습니다. 코드를 선택하여 직접 복사하세요.",
         cli: "복사",
         copyCliCommand: "CLI 명령 복사 (외부 / 대체용)",
         connect: "연결",
    ```
  - `web/src/i18n/pt.ts`

    ```diff
    @@ -447,16 +447,19 @@ export const pt: Translations = {
       oauth: {
         title: "Inícios de sessão de fornecedor (OAuth)",
         providerLogins: "Inícios de sessão de fornecedor (OAuth)",
    -    description: "{connected} de {total} fornecedores OAuth ligados. Os fluxos de início de sessão são executados via CLI; clique em Copiar comando e cole num terminal para configurar.",
    +    description:
    +      "{connected} de {total} fornecedores OAuth ligados. Use Iniciar sessão para fluxos suportados pelo painel; os comandos CLI continuam disponíveis para configuração externa ou de recurso.",
         connected: "Ligado",
         expired: "Expirado",
    -    notConnected: "Não ligado. Execute {command} num terminal.",
    +    notConnected: "Não ligado. Use Iniciar sessão quando disponível, ou execute {command} num terminal.",
         runInTerminal: "num terminal.",
         noProviders: "Não foram detetados fornecedores compatíveis com OAuth.",
         login: "Iniciar sessão",
         disconnect: "Desligar",
         managedExternally: "Gerido externamente",
         copied: "Copiado ✓",
    +    copyCode: "Copiar código",
    +    copyFailed: "Não foi possível copiar automaticamente. Selecione o código e copie-o manualmente.",
         cli: "Copiar",
         copyCliCommand: "Copiar comando CLI (para externo / fallback)",
         connect: "Ligar",
    ```
  - `web/src/i18n/ru.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const ru: Translations = {
       oauth: {
         title: "Входы провайдеров (OAuth)",
         providerLogins: "Входы провайдеров (OAuth)",
    -    description: "Подключено {connected} из {total} OAuth-провайдеров. Процесс входа в настоящее время выполняется через CLI; нажмите «Скопировать команду» и вставьте в терминал для настройки.",
    +    description:
    +      "Подключено {connected} из {total} OAuth-провайдеров. Используйте «Войти» для процессов, поддерживаемых панелью; команды CLI остаются доступными для внешней или резервной настройки.",
         connected: "Подключено",
         expired: "Срок истёк",
    -    notConnected: "Не подключено. Выполните {command} в терминале.",
    +    notConnected: "Не подключено. Используйте «Войти», если доступно, или выполните {command} в терминале.",
         runInTerminal: "в терминале.",
         noProviders: "OAuth-совместимые провайдеры не обнаружены.",
         login: "Войти",
         disconnect: "Отключить",
         managedExternally: "Управляется извне",
         copied: "Скопировано ✓",
    +    copyCode: "Скопировать код",
    +    copyFailed: "Не удалось скопировать автоматически. Выделите код и скопируйте его вручную.",
         cli: "Копировать",
         copyCliCommand: "Скопировать CLI-команду (для внешнего / резервного варианта)",
         connect: "Подключить",
    ```
  - `web/src/i18n/tr.ts`

    ```diff
    @@ -446,16 +446,19 @@ export const tr: Translations = {
       oauth: {
         title: "Sağlayıcı Girişleri (OAuth)",
         providerLogins: "Sağlayıcı Girişleri (OAuth)",
    -    description: "{connected}/{total} OAuth sağlayıcısı bağlandı. Giriş akışları şu anda CLI üzerinden çalışır; Komutu kopyala'ya tıklayın ve kurmak için bir terminale yapıştırın.",
    +    description:
    +      "{connected}/{total} OAuth sağlayıcısı bağlandı. Panel destekli akışlar için Giriş'i kullanın; CLI komutları harici veya yedek kurulum için kullanılabilir.",
         connected: "Bağlandı",
         expired: "Süresi doldu",
    -    notConnected: "Bağlı değil. Bir terminalde {command} komutunu çalıştırın.",
    +    notConnected: "Bağlı değil. Mümkünse Giriş'i kullanın veya bir terminalde {command} komutunu çalıştırın.",
         runInTerminal: "bir terminalde.",
         noProviders: "OAuth uyumlu sağlayıcı algılanmadı.",
         login: "Giriş",
         disconnect: "Bağlantıyı kes",
         managedExternally: "Harici olarak yönetiliyor",
         copied: "Kopyalandı ✓",
    +    copyCode: "Kodu kopyala",
    +    copyFailed: "Otomatik olarak kopyalanamadı. Kodu seçip elle kopyalayın.",
         cli: "Kopyala",
         copyCliCommand: "CLI komutunu kopyala (harici / yedek için)",
         connect: "Bağlan",
    ```
  - `web/src/i18n/types.ts`

    ```diff
    @@ -530,6 +530,8 @@ export interface Translations {
         disconnect: string;
         managedExternally: string;
         copied: string;
    +    copyCode: string;
    +    copyFailed: string;
         cli: string;
         copyCliCommand: string;
         connect: string;
    ```
  - `web/src/i18n/uk.ts`

    ```diff
    @@ -447,16 +447,19 @@ export const uk: Translations = {
       oauth: {
         title: "Входи постачальників (OAuth)",
         providerLogins: "Входи постачальників (OAuth)",
    -    description: "Підключено {connected} з {total} постачальників OAuth. Процеси входу наразі виконуються через CLI; натисніть «Скопіювати команду» та вставте у термінал, щоб налаштувати.",
    +    description:
    +      "Підключено {connected} з {total} постачальників OAuth. Використовуйте «Увійти» для процесів, підтримуваних панеллю; команди CLI залишаються доступними для зовнішнього або резервного налаштування.",
         connected: "Підключено",
         expired: "Прострочено",
    -    notConnected: "Не підключено. Виконайте {command} у терміналі.",
    +    notConnected: "Не підключено. Використовуйте «Увійти», якщо доступно, або виконайте {command} у терміналі.",
         runInTerminal: "у терміналі.",
         noProviders: "Не виявлено постачальників із підтримкою OAuth.",
         login: "Увійти",
         disconnect: "Відключити",
         managedExternally: "Керується ззовні",
         copied: "Скопійовано ✓",
    +    copyCode: "Скопіювати код",
    +    copyFailed: "Не вдалося скопіювати автоматично. Виділіть код і скопіюйте його вручну.",
         cli: "Копіювати",
         copyCliCommand: "Скопіювати CLI-команду (для зовнішнього / резервного варіанту)",
         connect: "Підключити",
    ```
  - `web/src/i18n/zh-hant.ts`

    ```diff
    @@ -445,16 +445,19 @@ export const zhHant: Translations = {
       oauth: {
         title: "提供者登入（OAuth）",
         providerLogins: "提供者登入（OAuth）",
    -    description: "已連線 {connected}/{total} 個 OAuth 提供者。登入流程目前透過 CLI 執行；請點擊「複製指令」並貼到終端機完成設定。",
    +    description:
    +      "已連線 {connected}/{total} 個 OAuth 提供者。儀表板支援的流程請使用「登入」；CLI 指令仍可用於外部或備用設定。",
         connected: "已連線",
         expired: "已過期",
    -    notConnected: "未連線。請在終端機執行 {command}。",
    +    notConnected: "未連線。可用時請使用「登入」，或在終端機執行 {command}。",
         runInTerminal: "於終端機。",
         noProviders: "未偵測到支援 OAuth 的提供者。",
         login: "登入",
         disconnect: "中斷連線",
         managedExternally: "由外部管理",
         copied: "已複製 ✓",
    +    copyCode: "複製代碼",
    +    copyFailed: "無法自動複製。請選取代碼並手動複製。",
         cli: "複製",
         copyCliCommand: "複製 CLI 指令（外部 / 備援用）",
         connect: "連線",
    ```
  - `web/src/i18n/zh.ts`

    ```diff
    @@ -440,16 +440,19 @@ export const zh: Translations = {
       oauth: {
         title: "提供商登录（OAuth）",
         providerLogins: "提供商登录（OAuth）",
    -    description: "已连接 {connected}/{total} 个 OAuth 提供商。登录流程目前通过 CLI 运行；点击「复制命令」并粘贴到终端中进行设置。",
    +    description:
    +      "已连接 {connected}/{total} 个 OAuth 提供商。仪表板支持的流程请使用「登录」；CLI 命令仍可用于外部或备用设置。",
         connected: "已连接",
         expired: "已过期",
    -    notConnected: "未连接。在终端中运行 {command}。",
    +    notConnected: "未连接。可用时请使用「登录」，或在终端中运行 {command}。",
         runInTerminal: "在终端中。",
         noProviders: "未检测到支持 OAuth 的提供商。",
         login: "登录",
         disconnect: "断开连接",
         managedExternally: "外部管理",
         copied: "已复制 ✓",
    +    copyCode: "复制代码",
    +    copyFailed: "无法自动复制。请选中代码并手动复制。",
         cli: "复制",
         copyCliCommand: "复制 CLI 命令（用于外部/备用方式）",
         connect: "连接",
    ```
  - `web/src/lib/api.test.ts`

    ```diff
    @@ -2,21 +2,28 @@ import { afterEach, describe, expect, it, vi } from "vitest";
     
     import { api } from "./api";
     
    +const SESSION_HEADER = "X-Hermes-Session-Token";
    +
     afterEach(() => {
       vi.restoreAllMocks();
       vi.unstubAllGlobals();
     });
     
    +function jsonFetchMock(body: unknown = { ok: true }) {
    +  return vi.fn<typeof fetch>(
    +    async () =>
    +      new Response(JSON.stringify(body), {
    +        headers: { "Content-Type": "application/json" },
    +        status: 200,
    +      }),
    +  );
    +}
    +
     describe("api.getModelOptions", () => {
       it("requests a live model refresh when asked", async () => {
         vi.stubGlobal("window", {});
     
    -    const fetchMock = vi.fn(async () =>
    -      new Response(JSON.stringify({ providers: [] }), {
    -        headers: { "Content-Type": "application/json" },
    -        status: 200,
    -      }),
    -    );
    +    const fetchMock = jsonFetchMock({ providers: [] });
         vi.stubGlobal("fetch", fetchMock);
     
         await api.getModelOptions({ refresh: true });
    ```

    ```diff
    @@ -30,12 +37,7 @@ describe("api.getModelOptions", () => {
       it("keeps explicit profile scoping when refreshing", async () => {
         vi.stubGlobal("window", {});
     
    -    const fetchMock = vi.fn(async () =>
    -      new Response(JSON.stringify({ providers: [] }), {
    -        headers: { "Content-Type": "application/json" },
    -        status: 200,
    -      }),
    -    );
    +    const fetchMock = jsonFetchMock({ providers: [] });
         vi.stubGlobal("fetch", fetchMock);
     
         await api.getModelOptions({ profile: "default", refresh: true });
    ```

    ```diff
    @@ -46,3 +48,59 @@ describe("api.getModelOptions", () => {
         );
       });
     });
    +
    +describe("api OAuth helpers", () => {
    +  it("starts OAuth login in gated mode without requiring an injected session token", async () => {
    +    vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
    +    const fetchMock = jsonFetchMock({
    +      flow: "device_code",
    +      session_id: "oauth-session",
    +    });
    +    vi.stubGlobal("fetch", fetchMock);
    +
    +    await api.startOAuthLogin("openai-codex");
    +
    +    expect(fetchMock).toHaveBeenCalledWith(
    +      "/api/providers/oauth/openai-codex/start",
    +      expect.objectContaining({
    +        body: "{}",
    +        credentials: "[redacted:secret-assignment]",
    +        method: "POST",
    +      }),
    +    );
    +    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
    +    expect(headers.get("Content-Type")).toBe("application/json");
    +    expect(headers.has(SESSION_HEADER)).toBe(false);
    +  });
    +
    +  it("still sends the injected session token for OAuth login in loopback mode", async () => {
    +    vi.stubGlobal("window", { __HERMES_SESSION_TOKEN__: "[redacted:secret-assignment]" });
    +    const fetchMock = jsonFetchMock({
    +      flow: "device_code",
    +      session_id: "oauth-session",
    +    });
    +    vi.stubGlobal("fetch", fetchMock);
    +
    +    await api.startOAuthLogin("openai-codex");
    +
    +    const headers = fetchMock.mock.calls[0][1]?.headers as Headers;
    +    expect(headers.get(SESSION_HEADER)).toBe("loopback-token");
    +  });
    +
    +  it("runs provider auth mutations in gated mode via cookie auth", async () => {
    +    vi.stubGlobal("window", { __HERMES_AUTH_REQUIRED__: true });
    +    const fetchMock = jsonFetchMock({ ok: true });
    +    vi.stubGlobal("fetch", fetchMock);
    +
    +    await api.disconnectOAuthProvider("anthropic");
    +    await api.submitOAuthCode("anthropic", "oauth-session", "code-123");
    +    await api.cancelOAuthSession("oauth-session");
    +    await api.revealEnvVar("OPENAI_API_KEY");
    +
    +    for (const call of fetchMock.mock.calls) {
    +      const init = call[1] as RequestInit;
    +      expect(init.credentials).toBe("include");
    +      expect((init.headers as Headers).has(SESSION_HEADER)).toBe(false);
    +    }
    +  });
    +});
    ```
  - `web/src/lib/api.ts`

    ```diff
    @@ -34,7 +34,6 @@ declare global {
         __HERMES_AUTH_REQUIRED__?: boolean;
       }
     }
    -let _sessionToken: string | null = null;
     const SESSION_HEADER = "X-Hermes-Session-Token";
     
     function setSessionHeader(headers: Headers, token: [redacted:secret-assignment] void {
    ```

    ```diff
    @@ -197,16 +196,6 @@ function pluginPath(name: string): string {
       return name.split("/").map(encodeURIComponent).join("/");
     }
     
    -async function getSessionToken(): Promise<string> {
    -  if (_sessionToken) return _sessionToken;
    -  const injected = window.__HERMES_SESSION_TOKEN__;
    -  if (injected) {
    -    _sessionToken = [redacted:secret-assignment]
    -    return _sessionToken;
    -  }
    -  throw new Error("Session token not available — page must be served by the Hermes dashboard server");
    -}
    -
     /**
      * Fetch a single-use ticket for a WebSocket upgrade in gated mode.
      *
    ```

    ```diff
    @@ -553,17 +542,12 @@ export const api = {
           headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ key }),
         }),
    -  revealEnvVar: async (key: string) => {
    -    const token = await getSessionToken();
    -    return fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
    +  revealEnvVar: (key: string) =>
    +    fetchJSON<{ key: string; value: string }>("/api/env/reveal", {
           method: "POST",
    -      headers: {
    -        "Content-Type": "application/json",
    -        [SESSION_HEADER]: token,
    -      },
    +      headers: { "Content-Type": "application/json" },
           body: JSON.stringify({ key }),
    -    });
    -  },
    +    }),
     
       // Cron jobs
       getCronJobs: (profile = "all") =>
    ```

    ```diff
    @@ -788,58 +772,42 @@ export const api = {
       // OAuth provider management
       getOAuthProviders: () =>
         fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
    -  disconnectOAuthProvider: async (providerId: string) => {
    -    const token = await getSessionToken();
    -    return fetchJSON<{ ok: boolean; provider: string }>(
    +  disconnectOAuthProvider: (providerId: string) =>
    +    fetchJSON<{ ok: boolean; provider: string }>(
           `/api/providers/oauth/${encodeURIComponent(providerId)}`,
           {
             method: "DELETE",
    -        headers: { [SESSION_HEADER]: token },
           },
    -    );
    -  },
    -  startOAuthLogin: async (providerId: string) => {
    -    const token = await getSessionToken();
    -    return fetchJSON<OAuthStartResponse>(
    +    ),
    +  startOAuthLogin: (providerId: string) =>
    +    fetchJSON<OAuthStartResponse>(
           `/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
           {
             method: "POST",
    -        headers: {
    -          "Content-Type": "application/json",
    -          [SESSION_HEADER]: token,
    -        },
    +        headers: { "Content-Type": "application/json" },
             body: "{}",
           },
    -    );
    -  },
    -  submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
    -    const token = await getSessionToken();
    -    return fetchJSON<OAuthSubmitResponse>(
    +    ),
    +  submitOAuthCode: (providerId: string, sessionId: string, code: string) =>
    +    fetchJSON<OAuthSubmitResponse>(
           `/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
           {
             method: "POST",
    -        headers: {
    -          "Content-Type": "application/json",
    -          [SESSION_HEADER]: token,
    -        },
    +        headers: { "Content-Type": "application/json" },
             body: JSON.stringify({ session_id: sessionId, code }),
           },
    -    );
    -  },
    +    ),
       pollOAuthSession: (providerId: string, sessionId: string) =>
         fetchJSON<OAuthPollResponse>(
           `/api/providers/oauth/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`,
         ),
    -  cancelOAuthSession: async (sessionId: string) => {
    -    const token = await getSessionToken();
    -    return fetchJSON<{ ok: boolean }>(
    +  cancelOAuthSession: (sessionId: string) =>
    +    fetchJSON<{ ok: boolean }>(
           `/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
           {
             method: "DELETE",
    -        headers: { [SESSION_HEADER]: token },
           },
    -    );
    -  },
    +    ),
     
       // Messaging platforms (gateway channels)
       getMessagingPlatforms: () =>
    ```
  - `web/src/lib/clipboard.test.ts`

    ```diff
    @@ -0,0 +1,114 @@
    +import { afterEach, describe, expect, it, vi } from "vitest";
    +import { copyTextToClipboard } from "./clipboard";
    +
    +const originalNavigator = globalThis.navigator;
    +const originalDocument = globalThis.document;
    +const originalWindow = globalThis.window;
    +
    +function setGlobal<K extends keyof typeof globalThis>(
    +  key: K,
    +  value: (typeof globalThis)[K] | undefined,
    +) {
    +  Object.defineProperty(globalThis, key, {
    +    configurable: true,
    +    value,
    +  });
    +}
    +
    +afterEach(() => {
    +  setGlobal("navigator", originalNavigator);
    +  setGlobal("document", originalDocument);
    +  setGlobal("window", originalWindow);
    +  vi.restoreAllMocks();
    +});
    +
    +describe("copyTextToClipboard", () => {
    +  it("uses navigator.clipboard when available", async () => {
    +    const writeText = vi.fn().mockResolvedValue(undefined);
    +    setGlobal(
    +      "navigator",
    +      { clipboard: { writeText } } as unknown as Navigator,
    +    );
    +    setGlobal("document", undefined);
    +
    +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
    +    expect(writeText).toHaveBeenCalledWith("CODEX-1234");
    +  });
    +
    +  it("falls back to selection copy when Clipboard API fails", async () => {
    +    const writeText = vi.fn().mockRejectedValue(new Error("not allowed"));
    +    const appendChild = vi.fn();
    +    const removeChild = vi.fn();
    +    const execCommand = vi.fn().mockReturnValue(true);
    +    const textarea = {
    +      focus: vi.fn(),
    +      select: vi.fn(),
    +      setAttribute: vi.fn(),
    +      setSelectionRange: vi.fn(),
    +      style: {},
    +      value: "",
    +    } as unknown as HTMLTextAreaElement;
    +
    +    setGlobal(
    +      "navigator",
    +      { clipboard: { writeText } } as unknown as Navigator,
    +    );
    +    setGlobal("document", {
    +      body: { appendChild, removeChild },
    +      createElement: vi.fn(() => textarea),
    +      execCommand,
    +      getSelection: vi.fn(() => null),
    +    } as unknown as Document);
    +
    +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
    +
    +    expect(writeText).toHaveBeenCalledWith("CODEX-1234");
    +    expect(textarea.value).toBe("CODEX-1234");
    +    expect(appendChild).toHaveBeenCalledWith(textarea);
    +    expect(textarea.select).toHaveBeenCalled();
    +    expect(execCommand).toHaveBeenCalledWith("copy");
    +    expect(removeChild).toHaveBeenCalledWith(textarea);
    +  });
    +
    +  it("uses selection copy directly in insecure browser contexts", async () => {
    +    const writeText = vi.fn().mockResolvedValue(undefined);
    +    const appendChild = vi.fn();
    +    const removeChild = vi.fn();
    +    const execCommand = vi.fn().mockReturnValue(true);
    +    const textarea = {
    +      focus: vi.fn(),
    +      select: vi.fn(),
    +      setAttribute: vi.fn(),
    +      setSelectionRange: vi.fn(),
    +      style: {},
    +      value: "",
    +    } as unknown as HTMLTextAreaElement;
    +
    +    setGlobal(
    +      "navigator",
    +      { clipboard: { writeText } } as unknown as Navigator,
    +    );
    +    setGlobal(
    +      "window",
    +      { isSecureContext: false } as unknown as Window & typeof globalThis,
    +    );
    +    setGlobal("document", {
    +      body: { appendChild, removeChild },
    +      createElement: vi.fn(() => textarea),
    +      execCommand,
    +      getSelection: vi.fn(() => null),
    +    } as unknown as Document);
    +
    +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(true);
    +
    +    expect(writeText).not.toHaveBeenCalled();
    +    expect(execCommand).toHaveBeenCalledWith("copy");
    +  });
    +
    +  it("returns false when no copy mechanism is available", async () => {
    +    setGlobal("navigator", {} as Navigator);
    +    setGlobal("document", undefined);
    +
    +    await expect(copyTextToClipboard("CODEX-1234")).resolves.toBe(false);
    +  });
    +});
    ```
  - `web/src/lib/clipboard.ts`

    ```diff
    @@ -0,0 +1,56 @@
    +export async function copyTextToClipboard(text: string): Promise<boolean> {
    +  const clipboard =
    +    typeof navigator === "undefined" ? undefined : navigator.clipboard;
    +  const secureContext =
    +    typeof window === "undefined" ? true : window.isSecureContext;
    +  if (secureContext && clipboard?.writeText) {
    +    try {
    +      await clipboard.writeText(text);
    +      return true;
    +    } catch {
    +      // Fall through to the selection-based copy path below.
    +    }
    +  }
    +
    +  if (typeof document === "undefined") {
    +    return false;
    +  }
    +
    +  const textarea = document.createElement("textarea");
    +  textarea.value = text;
    +  textarea.setAttribute("readonly", "");
    +  textarea.style.position = "fixed";
    +  textarea.style.top = "-1000px";
    +  textarea.style.left = "-1000px";
    +  textarea.style.opacity = "0";
    +  document.body.appendChild(textarea);
    +
    +  const selection = document.getSelection();
    +  const ranges: Range[] = [];
    +  if (selection) {
    +    for (let i = 0; i < selection.rangeCount; i += 1) {
    +      ranges.push(selection.getRangeAt(i));
    +    }
    +  }
    +
    +  textarea.focus();
    +  textarea.select();
    +  textarea.setSelectionRange(0, textarea.value.length);
    +
    +  let copied = false;
    +  try {
    +    copied = document.execCommand("copy");
    +  } catch {
    +    copied = false;
    +  } finally {
    +    document.body.removeChild(textarea);
    +    if (selection) {
    +      selection.removeAllRanges();
    +      for (const range of ranges) {
    +        selection.addRange(range);
    +      }
    +    }
    +  }
    +
    +  return copied;
    +}
    ```
- **#61342: fix(dashboard-auth): redirect password providers to /login instead of 500**. closed without merge · fix tier `suggested`
  - maintainer direction: category `superseded_by_pr`; implication `superseded`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#62786: fix(dashboard): render password form for supports_password providers in OAuthLoginModal**. closed without merge · fix tier `suggested`
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `ed9e8ba097ec8075b11bb3f36538b798b90fa0a0`, `#62782`
  - _suggested-tier fix detail withheld; re-request with `?tier=all` to include it._
- **#55575: fix(dashboard): skip auto-sso for password providers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `473407174bf304a083df166e40173503ea9eee89`, `#61330`
- **#55988: fix(dashboard): guard auth_login against password-only providers to prevent NotImplementedError crash**. closed without merge
  - maintainer direction: category `uncategorized`; implication `needs_llm`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`
- **#56191: fix(dashboard): skip auto-SSO redirect for password-only providers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54887`, `#54887`, `#56191`, `#54887`, `#54887`, `#55988`
- **#56627: fix(dashboard): redirect password-only auth to /login instead of /auth/login**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
- **#57959: fix: dashboard basic auth (password-only provider) no longer crashes on first page load (#57868)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#54887`, `#54887`, `#55130`, `#54846`, `#56886`, `#54887`, `#56886`
- **#58007: fix(auth): skip auto-SSO redirect for password-only providers (#57868)**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `473407174bf304a083df166e40173503ea9eee89`, `#61330`
- **#58315: fix(dashboard-auth): prevent 500 crash on GET /auth/login?provider=basic (#58166)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`
- **#58926: fix(dashboard): skip auto-SSO redirect for password-only providers**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#54887`, `#54887`
- **#59643: fix(dashboard-auth): skip auto-SSO redirect for password-only providers**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `3e24b16f566045399012bc1185fe0cdb6e1a1be9`, `#54887`
- **#60123: fix(dashboard): skip auto-SSO for password-only providers (BasicAuthProvider)**. closed without merge
  - maintainer direction: category `duplicate_of`; implication `duplicate`; refs `#54887`, `#54887`
- **#60247: fix(dashboard-auth): route password-capable providers to /login instead of /auth/login**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#61330`

## Fix status

- Merged to main in PR #61330 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-09`.
- Subsystem: `dashboard`
