Dashboard 500 when basic auth is sole provider
NousResearch/hermes-agent · cluster 55130 · dashboard
Data as of
Nothing open on GitHub. Fix merged in #61330. Check it covers your case.
The short answer
- Known?
- Yes. 48 related items grouped here, 25 issues already closed.
- Fix event
- A fix was merged to main in PR #61330. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- 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.
Open issues0
Open PRs0
Fixes on record
-
#61330 fix(dashboard): support mobile/hosted OpenAI OAuth login (salvage #61281)
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)Show the diff
-
hermes_cli/dashboard_auth/middleware.py@@ -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 @@ -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 @@ -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@@ -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@@ -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. @@ -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@@ -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@@ -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@@ -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@@ -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"; @@ -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 @@ -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 }, []); @@ -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" @@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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@@ -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 }); @@ -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 }); @@ -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@@ -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 { @@ -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. * @@ -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") => @@ -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@@ -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@@ -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; +}
-
22 PRs closed without merging
From the close comments: done elsewhere 12 · superseded by another PR 5 · duplicate 3 · other 2
-
#55133 fix(dashboard): skip auto-SSO for password-only auth providers (500 on basic-auth-only dashboard) · done elsewhere →
3e24b16,#54887 -
#55235 fix(dashboard): skip auto-SSO redirect for password-only providers (#55130) · superseded by another PR →
#54887 -
#55330 fix(cli): skip auto-SSO redirect for password-only auth providers · done elsewhere →
3e24b16 -
#55575 fix(dashboard): skip auto-sso for password providers · superseded by another PR →
4734071,#61330 -
#55988 fix(dashboard): guard auth_login against password-only providers to prevent NotImplementedError crash · other →
3e24b16
Show 17 more
-
#55993 fix(dashboard): handle NotImplementedError on logout for password-only providers (#55985) · done elsewhere →
3e24b16 -
#56082 fix: skip auto-SSO redirect for password-only auth providers · done elsewhere →
3e24b16,#54887 -
#56191 fix(dashboard): skip auto-SSO redirect for password-only providers · superseded by another PR →
#54887,#56191,#55988 -
#56627 fix(dashboard): redirect password-only auth to /login instead of /auth/login · done elsewhere →
3e24b16,#54887 -
#57214 fix(dashboard-auth): skip auto-SSO for password-only provider (500 on login) · done elsewhere →
3e24b16,#54887 -
#57513 fix(gateway): use getattr for __cause__ in _is_transient_network_error · other →
#57294,#57298,#57299 -
#57959 fix: dashboard basic auth (password-only provider) no longer crashes on first page load (#57868) · duplicate →
#54887,#55130,#54846,#56886 -
#58007 fix(auth): skip auto-SSO redirect for password-only providers (#57868) · done elsewhere →
3e24b16,4734071,#61330 -
#58247 fix(dashboard-auth): skip password-only providers in auto-SSO redirect (#58237) · done elsewhere →
3e24b16,#54887 - #58315 fix(dashboard-auth): prevent 500 crash on GET /auth/login?provider=basic (#58166) · duplicate
-
#58926 fix(dashboard): skip auto-SSO redirect for password-only providers · superseded by another PR →
#54887 -
#59643 fix(dashboard-auth): skip auto-SSO redirect for password-only providers · done elsewhere →
3e24b16,#54887 -
#60123 fix(dashboard): skip auto-SSO for password-only providers (BasicAuthProvider) · duplicate →
#54887 -
#60247 fix(dashboard-auth): route password-capable providers to /login instead of /auth/login · done elsewhere →
#61330 -
#60978 fix(#55985): guard start_login() call — BasicAuthProvider has no OAuth redirect flow · done elsewhere →
3e24b16 - #61342 fix(dashboard-auth): redirect password providers to /login instead of 500 · superseded by another PR
-
#62786 fix(dashboard): render password form for supports_password providers in OAuthLoginModal · done elsewhere →
ed9e8ba,#62782
Closed issues · 25
- Closed#55130Dashboard 500s on every page load when basic (password) auth is the only provider — auto-SSO calls start_login() on a password-only provider
- Closed#55498Dashboard: BasicAuthProvider crashes with NotImplementedError on /auth/login
- Closed#55985Dashboard logout crashes container via NotImplementedError in BasicAuthProvider
- Closed#56067Dashboard 500 on password-only auth provider: _auto_sso_response redirects to /auth/login instead of /login
- Closed#56130Dashboard returns HTTP 500 on non-loopback bind with basic auth (BasicAuthProvider auto-redirect crash)
- Closed#57211Dashboard 500s on login when basic_auth is the only provider (auto-SSO calls start_login() on a password provider)
- Closed#57220Dashboard returns HTTP 500 on first visit when basic is the only auth provider (auto-SSO redirects into OAuth-only route)
- Closed#57294fix(dashboard): password-only providers crash on /auth/login route
- Closed#57482LAN/local dashboard access requires password after June 2026 auth hardening
- Closed#57868Dashboard basic auth (password-only provider) crashes with 500 on first page load
- Closed#58020Dashboard /auth/login?provider=basic returns 500 — BasicAuthProvider.start_login() raises NotImplementedError
- Closed#58166fix(dashboard-auth): GET /auth/login?provider=basic returns HTTP 500 (NotImplementedError in BasicAuthProvider.start_login)
- Closed#58237[Bug]: Dashboard auto-SSO redirect 500s when only basic auth provider is configured
- Closed#58626Dashboard: BasicAuthProvider triggers NotImplementedError when binding to 0.0.0.0
- Closed#58810Dashboard: 500 on unauthenticated `/` when the only auth provider is password-only (auto-SSO redirects into OAuth start_login)
- Closed#58921500 Internal Server Error when BasicAuthProvider is the only auth provider (auto-SSO crashes on password-only provider)
- Closed#59748v0.18.0: Dashboard /auth/login crashes when BasicAuth is sole session provider
- Closed#60105Dashboard returns 500 on / when the only registered auth provider is password-only (BasicAuthProvider)
- Closed#60660Dashboard: root '/' returns 500 when sole auth provider is password-only (basic)
- Closed#61341dashboard auth: /auth/login?provider=basic returns 500 for password-only providers
- Closed#62463Dashboard auto-SSO redirect crashes (500) when the sole configured provider is BasicAuthProvider
- Closed#62782Dashboard Basic Auth login page triggers OAuth redirect instead of rendering password form (500 Internal Server Error)
- Closed#64424Dashboard auto-SSO redirect breaks for basic_auth (password-only) providers
- Closed#65327BasicAuthProvider 缺少 supports_session=False 导致 Dashboard 自动 SSO 跳转时 500 错误
- Closed#67460Dashboard basic auth: unauthenticated visit 500s (single-provider redirect goes to the OAuth kick-off route)
How this cluster was grouped
-
Link evidence (
partly structural links): 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 item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- dashboard
- Merged PRs
- #61330
- Closed issues
- 25
- Attempts
- 22 PRs closed without merging
- Fix link checked
- 9 Jul 2026