mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat: dashboard OAuth provider management
Add OAuth provider management to the Hermes dashboard with full
lifecycle support for Anthropic (PKCE), Nous and OpenAI Codex
(device-code) flows.
## Backend (hermes_cli/web_server.py)
- 6 new API endpoints:
GET /api/providers/oauth — list providers with connection status
POST /api/providers/oauth/{id}/start — initiate PKCE or device-code
POST /api/providers/oauth/{id}/submit — exchange PKCE auth code
GET /api/providers/oauth/{id}/poll/{session} — poll device-code
DELETE /api/providers/oauth/{id} — disconnect provider
DELETE /api/providers/oauth/sessions/{id} — cancel pending session
- OAuth constants imported from anthropic_adapter (no duplication)
- Blocking I/O wrapped in run_in_executor for async safety
- In-memory session store with 15-minute TTL and automatic GC
- Auth token required on all mutating endpoints
## Frontend
- OAuthLoginModal — PKCE (paste auth code) and device-code (poll) flows
- OAuthProvidersCard — status, token preview, connect/disconnect actions
- Toast fix: createPortal to document.body for correct z-index
- App.tsx: skip animation key bump on initial mount (prevent double-mount)
- Integrated into the Env/Keys page
This commit is contained in:
parent
2773b18b56
commit
247929b0dd
11 changed files with 1789 additions and 96 deletions
|
|
@ -9,11 +9,16 @@ Usage:
|
|||
python -m hermes_cli.main web --port 8080
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
|
@ -334,19 +339,20 @@ async def get_status():
|
|||
|
||||
|
||||
@app.get("/api/sessions")
|
||||
async def get_sessions():
|
||||
async def get_sessions(limit: int = 20, offset: int = 0):
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
sessions = db.list_sessions_rich(limit=20)
|
||||
sessions = db.list_sessions_rich(limit=limit, offset=offset)
|
||||
total = db.session_count()
|
||||
now = time.time()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
return sessions
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
except Exception as e:
|
||||
|
|
@ -552,6 +558,881 @@ async def reveal_env_var(body: EnvVarReveal, request: Request):
|
|||
return {"key": body.key, "value": value}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth provider endpoints — status + disconnect (Phase 1)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Phase 1 surfaces *which OAuth providers exist* and whether each is
|
||||
# connected, plus a disconnect button. The actual login flow (PKCE for
|
||||
# Anthropic, device-code for Nous/Codex) still runs in the CLI for now;
|
||||
# Phase 2 will add in-browser flows. For unconnected providers we return
|
||||
# the canonical ``hermes auth add <provider>`` command so the dashboard
|
||||
# can surface a one-click copy.
|
||||
|
||||
|
||||
def _truncate_token(value: Optional[str], visible: int = 6) -> str:
|
||||
"""Return ``...XXXXXX`` (last N chars) for safe display in the UI.
|
||||
|
||||
We never expose more than the trailing ``visible`` characters of an
|
||||
OAuth access token. JWT prefixes (the part before the first dot) are
|
||||
stripped first when present so the visible suffix is always part of
|
||||
the signing region rather than a meaningless header chunk.
|
||||
"""
|
||||
if not value:
|
||||
return ""
|
||||
s = str(value)
|
||||
if "." in s and s.count(".") >= 2:
|
||||
# Looks like a JWT — show the trailing piece of the signature only.
|
||||
s = s.rsplit(".", 1)[-1]
|
||||
if len(s) <= visible:
|
||||
return s
|
||||
return f"…{s[-visible:]}"
|
||||
|
||||
|
||||
def _anthropic_oauth_status() -> Dict[str, Any]:
|
||||
"""Combined status across the three Anthropic credential sources we read.
|
||||
|
||||
Hermes resolves Anthropic creds in this order at runtime:
|
||||
1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow
|
||||
2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto)
|
||||
3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars
|
||||
The dashboard reports the highest-priority source that's actually present.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import (
|
||||
read_hermes_oauth_credentials,
|
||||
read_claude_code_credentials,
|
||||
_HERMES_OAUTH_FILE,
|
||||
)
|
||||
except ImportError:
|
||||
read_claude_code_credentials = None # type: ignore
|
||||
read_hermes_oauth_credentials = None # type: ignore
|
||||
_HERMES_OAUTH_FILE = None # type: ignore
|
||||
|
||||
hermes_creds = None
|
||||
if read_hermes_oauth_credentials:
|
||||
try:
|
||||
hermes_creds = read_hermes_oauth_credentials()
|
||||
except Exception:
|
||||
hermes_creds = None
|
||||
if hermes_creds and hermes_creds.get("accessToken"):
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "hermes_pkce",
|
||||
"source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})",
|
||||
"token_preview": _truncate_token(hermes_creds.get("accessToken")),
|
||||
"expires_at": hermes_creds.get("expiresAt"),
|
||||
"has_refresh_token": bool(hermes_creds.get("refreshToken")),
|
||||
}
|
||||
|
||||
cc_creds = None
|
||||
if read_claude_code_credentials:
|
||||
try:
|
||||
cc_creds = read_claude_code_credentials()
|
||||
except Exception:
|
||||
cc_creds = None
|
||||
if cc_creds and cc_creds.get("accessToken"):
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "claude_code",
|
||||
"source_label": "Claude Code (~/.claude/.credentials.json)",
|
||||
"token_preview": _truncate_token(cc_creds.get("accessToken")),
|
||||
"expires_at": cc_creds.get("expiresAt"),
|
||||
"has_refresh_token": bool(cc_creds.get("refreshToken")),
|
||||
}
|
||||
|
||||
env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN")
|
||||
if env_token:
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "env_var",
|
||||
"source_label": "ANTHROPIC_TOKEN environment variable",
|
||||
"token_preview": _truncate_token(env_token),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
}
|
||||
return {"logged_in": False, "source": None}
|
||||
|
||||
|
||||
def _claude_code_only_status() -> Dict[str, Any]:
|
||||
"""Surface Claude Code CLI credentials as their own provider entry.
|
||||
|
||||
Independent of the Anthropic entry above so users can see whether their
|
||||
Claude Code subscription tokens are actively flowing into Hermes even
|
||||
when they also have a separate Hermes-managed PKCE login.
|
||||
"""
|
||||
try:
|
||||
from agent.anthropic_adapter import read_claude_code_credentials
|
||||
creds = read_claude_code_credentials()
|
||||
except Exception:
|
||||
creds = None
|
||||
if creds and creds.get("accessToken"):
|
||||
return {
|
||||
"logged_in": True,
|
||||
"source": "claude_code_cli",
|
||||
"source_label": "~/.claude/.credentials.json",
|
||||
"token_preview": _truncate_token(creds.get("accessToken")),
|
||||
"expires_at": creds.get("expiresAt"),
|
||||
"has_refresh_token": bool(creds.get("refreshToken")),
|
||||
}
|
||||
return {"logged_in": False, "source": None}
|
||||
|
||||
|
||||
# Provider catalog. The order matters — it's how we render the UI list.
|
||||
# ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard
|
||||
# fallback while Phase 2 (in-browser flows) isn't built yet.
|
||||
# ``flow`` describes the OAuth shape so the future modal can pick the
|
||||
# right UI: ``pkce`` = open URL + paste callback code, ``device_code`` =
|
||||
# show code + verification URL + poll, ``external`` = read-only (delegated
|
||||
# to a third-party CLI like Claude Code or Qwen).
|
||||
_OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "anthropic",
|
||||
"name": "Anthropic (Claude API)",
|
||||
"flow": "pkce",
|
||||
"cli_command": "hermes auth add anthropic",
|
||||
"docs_url": "https://docs.claude.com/en/api/getting-started",
|
||||
"status_fn": _anthropic_oauth_status,
|
||||
},
|
||||
{
|
||||
"id": "claude-code",
|
||||
"name": "Claude Code (subscription)",
|
||||
"flow": "external",
|
||||
"cli_command": "claude setup-token",
|
||||
"docs_url": "https://docs.claude.com/en/docs/claude-code",
|
||||
"status_fn": _claude_code_only_status,
|
||||
},
|
||||
{
|
||||
"id": "nous",
|
||||
"name": "Nous Portal",
|
||||
"flow": "device_code",
|
||||
"cli_command": "hermes auth add nous",
|
||||
"docs_url": "https://portal.nousresearch.com",
|
||||
"status_fn": None, # dispatched via auth.get_nous_auth_status
|
||||
},
|
||||
{
|
||||
"id": "openai-codex",
|
||||
"name": "OpenAI Codex (ChatGPT)",
|
||||
"flow": "device_code",
|
||||
"cli_command": "hermes auth add openai-codex",
|
||||
"docs_url": "https://platform.openai.com/docs",
|
||||
"status_fn": None, # dispatched via auth.get_codex_auth_status
|
||||
},
|
||||
{
|
||||
"id": "qwen-oauth",
|
||||
"name": "Qwen (via Qwen CLI)",
|
||||
"flow": "external",
|
||||
"cli_command": "hermes auth add qwen-oauth",
|
||||
"docs_url": "https://github.com/QwenLM/qwen-code",
|
||||
"status_fn": None, # dispatched via auth.get_qwen_auth_status
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
|
||||
"""Dispatch to the right status helper for an OAuth provider entry."""
|
||||
if status_fn is not None:
|
||||
try:
|
||||
return status_fn()
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
try:
|
||||
from hermes_cli import auth as hauth
|
||||
if provider_id == "nous":
|
||||
raw = hauth.get_nous_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "nous_portal",
|
||||
"source_label": raw.get("portal_base_url") or "Nous Portal",
|
||||
"token_preview": _truncate_token(raw.get("access_token")),
|
||||
"expires_at": raw.get("access_expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
if provider_id == "openai-codex":
|
||||
raw = hauth.get_codex_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": raw.get("source") or "openai_codex",
|
||||
"source_label": raw.get("auth_mode") or "OpenAI Codex",
|
||||
"token_preview": _truncate_token(raw.get("api_key")),
|
||||
"expires_at": None,
|
||||
"has_refresh_token": False,
|
||||
"last_refresh": raw.get("last_refresh"),
|
||||
}
|
||||
if provider_id == "qwen-oauth":
|
||||
raw = hauth.get_qwen_auth_status()
|
||||
return {
|
||||
"logged_in": bool(raw.get("logged_in")),
|
||||
"source": "qwen_cli",
|
||||
"source_label": raw.get("auth_store_path") or "Qwen CLI",
|
||||
"token_preview": _truncate_token(raw.get("access_token")),
|
||||
"expires_at": raw.get("expires_at"),
|
||||
"has_refresh_token": bool(raw.get("has_refresh_token")),
|
||||
}
|
||||
except Exception as e:
|
||||
return {"logged_in": False, "error": str(e)}
|
||||
return {"logged_in": False}
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth")
|
||||
async def list_oauth_providers():
|
||||
"""Enumerate every OAuth-capable LLM provider with current status.
|
||||
|
||||
Response shape (per provider):
|
||||
id stable identifier (used in DELETE path)
|
||||
name human label
|
||||
flow "pkce" | "device_code" | "external"
|
||||
cli_command fallback CLI command for users to run manually
|
||||
docs_url external docs/portal link for the "Learn more" link
|
||||
status:
|
||||
logged_in bool — currently has usable creds
|
||||
source short slug ("hermes_pkce", "claude_code", ...)
|
||||
source_label human-readable origin (file path, env var name)
|
||||
token_preview last N chars of the token, never the full token
|
||||
expires_at ISO timestamp string or null
|
||||
has_refresh_token bool
|
||||
"""
|
||||
providers = []
|
||||
for p in _OAUTH_PROVIDER_CATALOG:
|
||||
status = _resolve_provider_status(p["id"], p.get("status_fn"))
|
||||
providers.append({
|
||||
"id": p["id"],
|
||||
"name": p["name"],
|
||||
"flow": p["flow"],
|
||||
"cli_command": p["cli_command"],
|
||||
"docs_url": p["docs_url"],
|
||||
"status": status,
|
||||
})
|
||||
return {"providers": providers}
|
||||
|
||||
|
||||
@app.delete("/api/providers/oauth/{provider_id}")
|
||||
async def disconnect_oauth_provider(provider_id: str, request: Request):
|
||||
"""Disconnect an OAuth provider. Token-protected (matches /env/reveal)."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid_ids:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider: {provider_id}. "
|
||||
f"Available: {', '.join(sorted(valid_ids))}",
|
||||
)
|
||||
|
||||
# Anthropic and claude-code clear the same Hermes-managed PKCE file
|
||||
# AND forget the Claude Code import. We don't touch ~/.claude/* directly
|
||||
# — that's owned by the Claude Code CLI; users can re-auth there if they
|
||||
# want to undo a disconnect.
|
||||
if provider_id in ("anthropic", "claude-code"):
|
||||
try:
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
if _HERMES_OAUTH_FILE.exists():
|
||||
_HERMES_OAUTH_FILE.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
# Also clear the credential pool entry if present.
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
clear_provider_auth("anthropic")
|
||||
except Exception:
|
||||
pass
|
||||
_log.info("oauth/disconnect: %s", provider_id)
|
||||
return {"ok": True, "provider": provider_id}
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import clear_provider_auth
|
||||
cleared = clear_provider_auth(provider_id)
|
||||
_log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared)
|
||||
return {"ok": bool(cleared), "provider": provider_id}
|
||||
except Exception as e:
|
||||
_log.exception("disconnect %s failed", provider_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# OAuth Phase 2 — in-browser PKCE & device-code flows
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Two flow shapes are supported:
|
||||
#
|
||||
# PKCE (Anthropic):
|
||||
# 1. POST /api/providers/oauth/anthropic/start
|
||||
# → server generates code_verifier + challenge, builds claude.ai
|
||||
# authorize URL, stashes verifier in _oauth_sessions[session_id]
|
||||
# → returns { session_id, flow: "pkce", auth_url }
|
||||
# 2. UI opens auth_url in a new tab. User authorizes, copies code.
|
||||
# 3. POST /api/providers/oauth/anthropic/submit { session_id, code }
|
||||
# → server exchanges (code + verifier) → tokens at console.anthropic.com
|
||||
# → persists to ~/.hermes/.anthropic_oauth.json AND credential pool
|
||||
# → returns { ok: true, status: "approved" }
|
||||
#
|
||||
# Device code (Nous, OpenAI Codex):
|
||||
# 1. POST /api/providers/oauth/{nous|openai-codex}/start
|
||||
# → server hits provider's device-auth endpoint
|
||||
# → gets { user_code, verification_url, device_code, interval, expires_in }
|
||||
# → spawns background poller thread that polls the token endpoint
|
||||
# every `interval` seconds until approved/expired
|
||||
# → stores poll status in _oauth_sessions[session_id]
|
||||
# → returns { session_id, flow: "device_code", user_code,
|
||||
# verification_url, expires_in, poll_interval }
|
||||
# 2. UI opens verification_url in a new tab and shows user_code.
|
||||
# 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id}
|
||||
# every 2s until status != "pending".
|
||||
# 4. On "approved" the background thread has already saved creds; UI
|
||||
# refreshes the providers list.
|
||||
#
|
||||
# Sessions are kept in-memory only (single-process FastAPI) and time out
|
||||
# after 15 minutes. A periodic cleanup runs on each /start call to GC
|
||||
# expired sessions so the dict doesn't grow without bound.
|
||||
|
||||
_OAUTH_SESSION_TTL_SECONDS = 15 * 60
|
||||
_oauth_sessions: Dict[str, Dict[str, Any]] = {}
|
||||
_oauth_sessions_lock = threading.Lock()
|
||||
|
||||
# Import OAuth constants from canonical source instead of duplicating
|
||||
from agent.anthropic_adapter import (
|
||||
_OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
_OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL,
|
||||
_OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI,
|
||||
_OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES,
|
||||
_generate_pkce as _generate_pkce_pair,
|
||||
)
|
||||
_ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://console.anthropic.com/oauth/authorize"
|
||||
|
||||
|
||||
def _gc_oauth_sessions() -> None:
|
||||
"""Drop expired sessions. Called opportunistically on /start."""
|
||||
cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS
|
||||
with _oauth_sessions_lock:
|
||||
stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff]
|
||||
for sid in stale:
|
||||
_oauth_sessions.pop(sid, None)
|
||||
|
||||
|
||||
def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]:
|
||||
"""Create + register a new OAuth session, return (session_id, session_dict)."""
|
||||
sid = secrets.token_urlsafe(16)
|
||||
sess = {
|
||||
"session_id": sid,
|
||||
"provider": provider_id,
|
||||
"flow": flow,
|
||||
"created_at": time.time(),
|
||||
"status": "pending", # pending | approved | denied | expired | error
|
||||
"error_message": None,
|
||||
}
|
||||
with _oauth_sessions_lock:
|
||||
_oauth_sessions[sid] = sess
|
||||
return sid, sess
|
||||
|
||||
|
||||
def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None:
|
||||
"""Persist Anthropic PKCE creds to both Hermes file AND credential pool.
|
||||
|
||||
Mirrors what auth_commands.add_command does so the dashboard flow leaves
|
||||
the system in the same state as ``hermes auth add anthropic``.
|
||||
"""
|
||||
from agent.anthropic_adapter import _HERMES_OAUTH_FILE
|
||||
payload = {
|
||||
"accessToken": access_token,
|
||||
"refreshToken": refresh_token,
|
||||
"expiresAt": expires_at_ms,
|
||||
}
|
||||
_HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
_HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
# Best-effort credential-pool insert. Failure here doesn't invalidate
|
||||
# the file write — pool registration only matters for the rotation
|
||||
# strategy, not for runtime credential resolution.
|
||||
try:
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
import uuid
|
||||
pool = load_pool("anthropic")
|
||||
# Avoid duplicate entries: delete any prior dashboard-issued OAuth entry
|
||||
existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")]
|
||||
for e in existing:
|
||||
try:
|
||||
pool.remove_entry(getattr(e, "id", ""))
|
||||
except Exception:
|
||||
pass
|
||||
entry = PooledCredential(
|
||||
provider="anthropic",
|
||||
id=uuid.uuid4().hex[:6],
|
||||
label="dashboard PKCE",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_pkce",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
expires_at_ms=expires_at_ms,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
except Exception as e:
|
||||
_log.warning("anthropic pool add (dashboard) failed: %s", e)
|
||||
|
||||
|
||||
def _start_anthropic_pkce() -> Dict[str, Any]:
|
||||
"""Begin PKCE flow. Returns the auth URL the UI should open."""
|
||||
verifier, challenge = _generate_pkce_pair()
|
||||
sid, sess = _new_oauth_session("anthropic", "pkce")
|
||||
sess["verifier"] = verifier
|
||||
sess["state"] = verifier # Anthropic round-trips verifier as state
|
||||
params = {
|
||||
"code": "true",
|
||||
"client_id": _ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
"response_type": "code",
|
||||
"redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI,
|
||||
"scope": _ANTHROPIC_OAUTH_SCOPES,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"state": verifier,
|
||||
}
|
||||
auth_url = f"{_ANTHROPIC_OAUTH_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}"
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "pkce",
|
||||
"auth_url": auth_url,
|
||||
"expires_in": _OAUTH_SESSION_TTL_SECONDS,
|
||||
}
|
||||
|
||||
|
||||
def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
|
||||
"""Exchange authorization code for tokens. Persists on success."""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce":
|
||||
raise HTTPException(status_code=404, detail="Unknown or expired session")
|
||||
if sess["status"] != "pending":
|
||||
return {"ok": False, "status": sess["status"], "message": sess.get("error_message")}
|
||||
|
||||
# Anthropic's redirect callback page formats the code as `<code>#<state>`.
|
||||
# Strip the state suffix if present (we already have the verifier server-side).
|
||||
parts = code_input.strip().split("#", 1)
|
||||
code = parts[0].strip()
|
||||
if not code:
|
||||
return {"ok": False, "status": "error", "message": "No code provided"}
|
||||
state_from_callback = parts[1] if len(parts) > 1 else ""
|
||||
|
||||
exchange_data = json.dumps({
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": _ANTHROPIC_OAUTH_CLIENT_ID,
|
||||
"code": code,
|
||||
"state": state_from_callback or sess["state"],
|
||||
"redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI,
|
||||
"code_verifier": sess["verifier"],
|
||||
}).encode()
|
||||
req = urllib.request.Request(
|
||||
_ANTHROPIC_OAUTH_TOKEN_URL,
|
||||
data=exchange_data,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "hermes-dashboard/1.0",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as resp:
|
||||
result = json.loads(resp.read().decode())
|
||||
except Exception as e:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = f"Token exchange failed: {e}"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
access_token = result.get("access_token", "")
|
||||
refresh_token = result.get("refresh_token", "")
|
||||
expires_in = int(result.get("expires_in") or 3600)
|
||||
if not access_token:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = "No access token returned"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
|
||||
expires_at_ms = int(time.time() * 1000) + (expires_in * 1000)
|
||||
try:
|
||||
_save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms)
|
||||
except Exception as e:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = f"Save failed: {e}"
|
||||
return {"ok": False, "status": "error", "message": sess["error_message"]}
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/pkce: anthropic login completed (session=%s)", session_id)
|
||||
return {"ok": True, "status": "approved"}
|
||||
|
||||
|
||||
async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]:
|
||||
"""Initiate a device-code flow (Nous or OpenAI Codex).
|
||||
|
||||
Calls the provider's device-auth endpoint via the existing CLI helpers,
|
||||
then spawns a background poller. Returns the user-facing display fields
|
||||
so the UI can render the verification page link + user code.
|
||||
"""
|
||||
from hermes_cli import auth as hauth
|
||||
if provider_id == "nous":
|
||||
from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY
|
||||
import httpx
|
||||
pconfig = PROVIDER_REGISTRY["nous"]
|
||||
portal_base_url = (
|
||||
os.getenv("HERMES_PORTAL_BASE_URL")
|
||||
or os.getenv("NOUS_PORTAL_BASE_URL")
|
||||
or pconfig.portal_base_url
|
||||
).rstrip("/")
|
||||
client_id = pconfig.client_id
|
||||
scope = pconfig.scope
|
||||
def _do_nous_device_request():
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
|
||||
return _request_device_code(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
scope=scope,
|
||||
)
|
||||
device_data = await asyncio.get_event_loop().run_in_executor(None, _do_nous_device_request)
|
||||
sid, sess = _new_oauth_session("nous", "device_code")
|
||||
sess["device_code"] = str(device_data["device_code"])
|
||||
sess["interval"] = int(device_data["interval"])
|
||||
sess["expires_at"] = time.time() + int(device_data["expires_in"])
|
||||
sess["portal_base_url"] = portal_base_url
|
||||
sess["client_id"] = client_id
|
||||
threading.Thread(
|
||||
target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}"
|
||||
).start()
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "device_code",
|
||||
"user_code": str(device_data["user_code"]),
|
||||
"verification_url": str(device_data["verification_uri_complete"]),
|
||||
"expires_in": int(device_data["expires_in"]),
|
||||
"poll_interval": int(device_data["interval"]),
|
||||
}
|
||||
|
||||
if provider_id == "openai-codex":
|
||||
# Codex uses fixed OpenAI device-auth endpoints; reuse the helper.
|
||||
sid, _ = _new_oauth_session("openai-codex", "device_code")
|
||||
# Use the helper but in a thread because it polls inline.
|
||||
# We can't extract just the start step without refactoring auth.py,
|
||||
# so we run the full helper in a worker and proxy the user_code +
|
||||
# verification_url back via the session dict. The helper prints
|
||||
# to stdout — we capture nothing here, just status.
|
||||
threading.Thread(
|
||||
target=_codex_full_login_worker, args=(sid,), daemon=True,
|
||||
name=f"oauth-codex-{sid[:6]}",
|
||||
).start()
|
||||
# Block briefly until the worker has populated the user_code, OR error.
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(sid)
|
||||
if s and (s.get("user_code") or s["status"] != "pending"):
|
||||
break
|
||||
await asyncio.sleep(0.1)
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(sid, {})
|
||||
if s.get("status") == "error":
|
||||
raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed")
|
||||
if not s.get("user_code"):
|
||||
raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code")
|
||||
return {
|
||||
"session_id": sid,
|
||||
"flow": "device_code",
|
||||
"user_code": s["user_code"],
|
||||
"verification_url": s["verification_url"],
|
||||
"expires_in": int(s.get("expires_in") or 900),
|
||||
"poll_interval": int(s.get("interval") or 5),
|
||||
}
|
||||
|
||||
raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow")
|
||||
|
||||
|
||||
def _nous_poller(session_id: str) -> None:
|
||||
"""Background poller that drives a Nous device-code flow to completion."""
|
||||
from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state
|
||||
from datetime import datetime, timezone
|
||||
import httpx
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
portal_base_url = sess["portal_base_url"]
|
||||
client_id = sess["client_id"]
|
||||
device_code = sess["device_code"]
|
||||
interval = sess["interval"]
|
||||
expires_in = max(60, int(sess["expires_at"] - time.time()))
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client:
|
||||
token_data = _poll_for_token(
|
||||
client=client,
|
||||
portal_base_url=portal_base_url,
|
||||
client_id=client_id,
|
||||
device_code=device_code,
|
||||
expires_in=expires_in,
|
||||
poll_interval=interval,
|
||||
)
|
||||
# Same post-processing as _nous_device_code_login (mint agent key)
|
||||
now = datetime.now(timezone.utc)
|
||||
token_ttl = int(token_data.get("expires_in") or 0)
|
||||
auth_state = {
|
||||
"portal_base_url": portal_base_url,
|
||||
"inference_base_url": token_data.get("inference_base_url"),
|
||||
"client_id": client_id,
|
||||
"scope": token_data.get("scope"),
|
||||
"token_type": token_data.get("token_type", "Bearer"),
|
||||
"access_token": token_data["access_token"],
|
||||
"refresh_token": token_data.get("refresh_token"),
|
||||
"obtained_at": now.isoformat(),
|
||||
"expires_at": (
|
||||
datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat()
|
||||
if token_ttl else None
|
||||
),
|
||||
"expires_in": token_ttl,
|
||||
}
|
||||
full_state = refresh_nous_oauth_from_state(
|
||||
auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0,
|
||||
force_refresh=False, force_mint=True,
|
||||
)
|
||||
# Save into credential pool same as auth_commands.py does
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
pool = load_pool("nous")
|
||||
entry = PooledCredential.from_dict("nous", {
|
||||
**full_state,
|
||||
"label": "dashboard device_code",
|
||||
"auth_type": AUTH_TYPE_OAUTH,
|
||||
"source": f"{SOURCE_MANUAL}:dashboard_device_code",
|
||||
"base_url": full_state.get("inference_base_url"),
|
||||
})
|
||||
pool.add_entry(entry)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: nous login completed (session=%s)", session_id)
|
||||
except Exception as e:
|
||||
_log.warning("nous device-code poll failed (session=%s): %s", session_id, e)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "error"
|
||||
sess["error_message"] = str(e)
|
||||
|
||||
|
||||
def _codex_full_login_worker(session_id: str) -> None:
|
||||
"""Run the complete OpenAI Codex device-code flow.
|
||||
|
||||
Codex doesn't use the standard OAuth device-code endpoints; it has its
|
||||
own ``/api/accounts/deviceauth/usercode`` (JSON body, returns
|
||||
``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body
|
||||
polled until 200). On success the response carries an
|
||||
``authorization_code`` + ``code_verifier`` that get exchanged at
|
||||
CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code.
|
||||
|
||||
The flow is replicated inline (rather than calling
|
||||
_codex_device_code_login) because that helper prints/blocks/polls in a
|
||||
single function — we need to surface the user_code to the dashboard the
|
||||
moment we receive it, well before polling completes.
|
||||
"""
|
||||
try:
|
||||
import httpx
|
||||
from hermes_cli.auth import (
|
||||
CODEX_OAUTH_CLIENT_ID,
|
||||
CODEX_OAUTH_TOKEN_URL,
|
||||
DEFAULT_CODEX_BASE_URL,
|
||||
)
|
||||
issuer = "https://auth.openai.com"
|
||||
|
||||
# Step 1: request device code
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
resp = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/usercode",
|
||||
json={"client_id": CODEX_OAUTH_CLIENT_ID},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}")
|
||||
device_data = resp.json()
|
||||
user_code = device_data.get("user_code", "")
|
||||
device_auth_id = device_data.get("device_auth_id", "")
|
||||
poll_interval = max(3, int(device_data.get("interval", "5")))
|
||||
if not user_code or not device_auth_id:
|
||||
raise RuntimeError("device-code response missing user_code or device_auth_id")
|
||||
verification_url = f"{issuer}/codex/device"
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
return
|
||||
sess["user_code"] = user_code
|
||||
sess["verification_url"] = verification_url
|
||||
sess["device_auth_id"] = device_auth_id
|
||||
sess["interval"] = poll_interval
|
||||
sess["expires_in"] = 15 * 60 # OpenAI's effective limit
|
||||
sess["expires_at"] = time.time() + sess["expires_in"]
|
||||
|
||||
# Step 2: poll until authorized
|
||||
deadline = time.time() + sess["expires_in"]
|
||||
code_resp = None
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
while time.time() < deadline:
|
||||
time.sleep(poll_interval)
|
||||
poll = client.post(
|
||||
f"{issuer}/api/accounts/deviceauth/token",
|
||||
json={"device_auth_id": device_auth_id, "user_code": user_code},
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
if poll.status_code == 200:
|
||||
code_resp = poll.json()
|
||||
break
|
||||
if poll.status_code in (403, 404):
|
||||
continue # user hasn't authorized yet
|
||||
raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}")
|
||||
|
||||
if code_resp is None:
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "expired"
|
||||
sess["error_message"] = "Device code expired before approval"
|
||||
return
|
||||
|
||||
# Step 3: exchange authorization_code for tokens
|
||||
authorization_code = code_resp.get("authorization_code", "")
|
||||
code_verifier = code_resp.get("code_verifier", "")
|
||||
if not authorization_code or not code_verifier:
|
||||
raise RuntimeError("device-auth response missing authorization_code/code_verifier")
|
||||
with httpx.Client(timeout=httpx.Timeout(15.0)) as client:
|
||||
token_resp = client.post(
|
||||
CODEX_OAUTH_TOKEN_URL,
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": authorization_code,
|
||||
"redirect_uri": f"{issuer}/deviceauth/callback",
|
||||
"client_id": CODEX_OAUTH_CLIENT_ID,
|
||||
"code_verifier": code_verifier,
|
||||
},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if token_resp.status_code != 200:
|
||||
raise RuntimeError(f"token exchange returned {token_resp.status_code}")
|
||||
tokens = token_resp.json()
|
||||
access_token = tokens.get("access_token", "")
|
||||
refresh_token = tokens.get("refresh_token", "")
|
||||
if not access_token:
|
||||
raise RuntimeError("token exchange did not return access_token")
|
||||
|
||||
# Persist via credential pool — same shape as auth_commands.add_command
|
||||
from agent.credential_pool import (
|
||||
PooledCredential,
|
||||
load_pool,
|
||||
AUTH_TYPE_OAUTH,
|
||||
SOURCE_MANUAL,
|
||||
)
|
||||
import uuid as _uuid
|
||||
pool = load_pool("openai-codex")
|
||||
base_url = (
|
||||
os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/")
|
||||
or DEFAULT_CODEX_BASE_URL
|
||||
)
|
||||
entry = PooledCredential(
|
||||
provider="openai-codex",
|
||||
id=_uuid.uuid4().hex[:6],
|
||||
label="dashboard device_code",
|
||||
auth_type=AUTH_TYPE_OAUTH,
|
||||
priority=0,
|
||||
source=f"{SOURCE_MANUAL}:dashboard_device_code",
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
base_url=base_url,
|
||||
)
|
||||
pool.add_entry(entry)
|
||||
with _oauth_sessions_lock:
|
||||
sess["status"] = "approved"
|
||||
_log.info("oauth/device: openai-codex login completed (session=%s)", session_id)
|
||||
except Exception as e:
|
||||
_log.warning("codex device-code worker failed (session=%s): %s", session_id, e)
|
||||
with _oauth_sessions_lock:
|
||||
s = _oauth_sessions.get(session_id)
|
||||
if s:
|
||||
s["status"] = "error"
|
||||
s["error_message"] = str(e)
|
||||
|
||||
|
||||
@app.post("/api/providers/oauth/{provider_id}/start")
|
||||
async def start_oauth_login(provider_id: str, request: Request):
|
||||
"""Initiate an OAuth login flow. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
_gc_oauth_sessions()
|
||||
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
|
||||
if provider_id not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}")
|
||||
catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id)
|
||||
if catalog_entry["flow"] == "external":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually",
|
||||
)
|
||||
try:
|
||||
if catalog_entry["flow"] == "pkce":
|
||||
return _start_anthropic_pkce()
|
||||
if catalog_entry["flow"] == "device_code":
|
||||
return await _start_device_code_flow(provider_id)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
_log.exception("oauth/start %s failed", provider_id)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
raise HTTPException(status_code=400, detail="Unsupported flow")
|
||||
|
||||
|
||||
class OAuthSubmitBody(BaseModel):
|
||||
session_id: str
|
||||
code: str
|
||||
|
||||
|
||||
@app.post("/api/providers/oauth/{provider_id}/submit")
|
||||
async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request):
|
||||
"""Submit the auth code for PKCE flows. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
if provider_id == "anthropic":
|
||||
return await asyncio.get_event_loop().run_in_executor(
|
||||
None, _submit_anthropic_pkce, body.session_id, body.code,
|
||||
)
|
||||
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
|
||||
|
||||
|
||||
@app.get("/api/providers/oauth/{provider_id}/poll/{session_id}")
|
||||
async def poll_oauth_session(provider_id: str, session_id: str):
|
||||
"""Poll a device-code session's status (no auth — read-only state)."""
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.get(session_id)
|
||||
if not sess:
|
||||
raise HTTPException(status_code=404, detail="Session not found or expired")
|
||||
if sess["provider"] != provider_id:
|
||||
raise HTTPException(status_code=400, detail="Provider mismatch for session")
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"status": sess["status"],
|
||||
"error_message": sess.get("error_message"),
|
||||
"expires_at": sess.get("expires_at"),
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/providers/oauth/sessions/{session_id}")
|
||||
async def cancel_oauth_session(session_id: str, request: Request):
|
||||
"""Cancel a pending OAuth session. Token-protected."""
|
||||
auth = request.headers.get("authorization", "")
|
||||
if auth != f"Bearer {_SESSION_TOKEN}":
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
with _oauth_sessions_lock:
|
||||
sess = _oauth_sessions.pop(session_id, None)
|
||||
if sess is None:
|
||||
return {"ok": False, "message": "session not found"}
|
||||
return {"ok": True, "session_id": session_id}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session detail endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
@ -608,6 +1489,7 @@ async def get_logs(
|
|||
lines: int = 100,
|
||||
level: Optional[str] = None,
|
||||
component: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
):
|
||||
from hermes_cli.logs import _read_tail, LOG_FILES
|
||||
|
||||
|
|
@ -623,14 +1505,34 @@ async def get_logs(
|
|||
except ImportError:
|
||||
COMPONENT_PREFIXES = {}
|
||||
|
||||
has_filters = bool(level or component)
|
||||
comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else ()
|
||||
# Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an
|
||||
# empty tuple as "must match a prefix" (startswith(()) is always False),
|
||||
# so passing () instead of None silently drops every line.
|
||||
min_level = level if level and level.upper() != "ALL" else None
|
||||
if component and component.lower() != "all":
|
||||
comp_prefixes = COMPONENT_PREFIXES.get(component)
|
||||
if comp_prefixes is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown component: {component}. "
|
||||
f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}",
|
||||
)
|
||||
else:
|
||||
comp_prefixes = None
|
||||
|
||||
has_filters = bool(min_level or comp_prefixes or search)
|
||||
result = _read_tail(
|
||||
log_path, min(lines, 500),
|
||||
log_path, min(lines, 500) if not search else 2000,
|
||||
has_filters=has_filters,
|
||||
min_level=level,
|
||||
min_level=min_level,
|
||||
component_prefixes=comp_prefixes,
|
||||
)
|
||||
# Post-filter by search term (case-insensitive substring match).
|
||||
# _read_tail doesn't support free-text search, so we filter here and
|
||||
# trim to the requested line count afterward.
|
||||
if search:
|
||||
needle = search.lower()
|
||||
result = [l for l in result if needle in l.lower()][-min(lines, 500):]
|
||||
return {"file": file, "lines": result}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { Activity, BarChart3, Clock, FileText, KeyRound, MessageSquare, Package, Settings } from "lucide-react";
|
||||
import StatusPage from "@/pages/StatusPage";
|
||||
import ConfigPage from "@/pages/ConfigPage";
|
||||
|
|
@ -36,8 +36,15 @@ const PAGE_COMPONENTS: Record<PageId, React.FC> = {
|
|||
export default function App() {
|
||||
const [page, setPage] = useState<PageId>("status");
|
||||
const [animKey, setAnimKey] = useState(0);
|
||||
const initialRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip the animation key bump on initial mount to avoid re-mounting
|
||||
// the default page component (which causes duplicate API requests).
|
||||
if (initialRef.current) {
|
||||
initialRef.current = false;
|
||||
return;
|
||||
}
|
||||
setAnimKey((k) => k + 1);
|
||||
}, [page]);
|
||||
|
||||
|
|
|
|||
365
web/src/components/OAuthLoginModal.tsx
Normal file
365
web/src/components/OAuthLoginModal.tsx
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { ExternalLink, Copy, X, Check, Loader2 } from "lucide-react";
|
||||
import { api, type OAuthProvider, type OAuthStartResponse } from "@/lib/api";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
/**
|
||||
* OAuthLoginModal — drives the in-browser OAuth flow for a single provider.
|
||||
*
|
||||
* Two variants share the same modal shell:
|
||||
*
|
||||
* - PKCE (Anthropic): user opens the auth URL in a new tab, authorizes,
|
||||
* pastes the resulting code back. We POST it to /submit which exchanges
|
||||
* the (code + verifier) pair for tokens server-side.
|
||||
*
|
||||
* - Device code (Nous, OpenAI Codex): we display the verification URL
|
||||
* and short user code; the backend polls the provider's token endpoint
|
||||
* in a background thread; we poll /poll/{session_id} every 2s for status.
|
||||
*
|
||||
* Edge cases handled:
|
||||
* - Popup blocker (we use plain anchor href + open in new tab; no popup
|
||||
* window.open which is more likely to be blocked).
|
||||
* - Modal dismissal mid-flight cancels the server-side session via DELETE.
|
||||
* - Code expiry surfaces as a clear error state with retry button.
|
||||
* - Polling continues to work if the user backgrounds the tab (setInterval
|
||||
* keeps firing in modern browsers; we guard against polls firing after
|
||||
* component unmount via an isMounted ref).
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
provider: OAuthProvider;
|
||||
onClose: () => void;
|
||||
onSuccess: (msg: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}
|
||||
|
||||
type Phase = "idle" | "starting" | "awaiting_user" | "submitting" | "polling" | "approved" | "error";
|
||||
|
||||
export function OAuthLoginModal({ provider, onClose, onSuccess, onError }: Props) {
|
||||
const [phase, setPhase] = useState<Phase>("starting");
|
||||
const [start, setStart] = useState<OAuthStartResponse | null>(null);
|
||||
const [pkceCode, setPkceCode] = useState("");
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [secondsLeft, setSecondsLeft] = useState<number | null>(null);
|
||||
const [codeCopied, setCodeCopied] = useState(false);
|
||||
const isMounted = useRef(true);
|
||||
const pollTimer = useRef<number | null>(null);
|
||||
|
||||
// Initiate flow on mount
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
api
|
||||
.startOAuthLogin(provider.id)
|
||||
.then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
// Auto-open the auth URL in a new tab
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
// Device-code: open the verification URL automatically
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Failed to start login: ${e}`);
|
||||
});
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
};
|
||||
// We only want to start the flow once on mount.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Tick the countdown
|
||||
useEffect(() => {
|
||||
if (secondsLeft === null) return;
|
||||
if (phase === "approved" || phase === "error") return;
|
||||
const tick = window.setInterval(() => {
|
||||
if (!isMounted.current) return;
|
||||
setSecondsLeft((s) => {
|
||||
if (s !== null && s <= 1) {
|
||||
// Session expired — transition to error state
|
||||
setPhase("error");
|
||||
setErrorMsg("Session expired. Click Retry to start a new login.");
|
||||
return 0;
|
||||
}
|
||||
return s !== null && s > 0 ? s - 1 : 0;
|
||||
});
|
||||
}, 1000);
|
||||
return () => window.clearInterval(tick);
|
||||
}, [secondsLeft, phase]);
|
||||
|
||||
// Device-code: poll backend every 2s
|
||||
useEffect(() => {
|
||||
if (!start || start.flow !== "device_code" || phase !== "polling") return;
|
||||
const sid = start.session_id;
|
||||
pollTimer.current = window.setInterval(async () => {
|
||||
try {
|
||||
const resp = await api.pollOAuthSession(provider.id, sid);
|
||||
if (!isMounted.current) return;
|
||||
if (resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else if (resp.status !== "pending") {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.error_message || `Login ${resp.status}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
}
|
||||
} catch (e) {
|
||||
// 404 = session expired/cleaned up; treat as error
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Polling failed: ${e}`);
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
}
|
||||
}, 2000);
|
||||
return () => {
|
||||
if (pollTimer.current !== null) window.clearInterval(pollTimer.current);
|
||||
};
|
||||
}, [start, phase, provider.id, provider.name, onSuccess, onClose]);
|
||||
|
||||
const handleSubmitPkceCode = async () => {
|
||||
if (!start || start.flow !== "pkce") return;
|
||||
if (!pkceCode.trim()) return;
|
||||
setPhase("submitting");
|
||||
setErrorMsg(null);
|
||||
try {
|
||||
const resp = await api.submitOAuthCode(provider.id, start.session_id, pkceCode.trim());
|
||||
if (!isMounted.current) return;
|
||||
if (resp.ok && resp.status === "approved") {
|
||||
setPhase("approved");
|
||||
onSuccess(`${provider.name} connected`);
|
||||
window.setTimeout(() => isMounted.current && onClose(), 1500);
|
||||
} else {
|
||||
setPhase("error");
|
||||
setErrorMsg(resp.message || "Token exchange failed");
|
||||
}
|
||||
} catch (e) {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Submit failed: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = async () => {
|
||||
// Cancel server session if still in flight
|
||||
if (start && phase !== "approved" && phase !== "error") {
|
||||
try {
|
||||
await api.cancelOAuthSession(start.session_id);
|
||||
} catch {
|
||||
// ignore — server-side TTL will clean it up anyway
|
||||
}
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCopyUserCode = async (code: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCodeCopied(true);
|
||||
window.setTimeout(() => isMounted.current && setCodeCopied(false), 1500);
|
||||
} catch {
|
||||
onError("Clipboard write failed");
|
||||
}
|
||||
};
|
||||
|
||||
// Backdrop click closes
|
||||
const handleBackdrop = (e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) handleClose();
|
||||
};
|
||||
|
||||
const fmtTime = (s: number | null) => {
|
||||
if (s === null) return "";
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${m}:${String(r).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
|
||||
onClick={handleBackdrop}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="oauth-modal-title"
|
||||
>
|
||||
<div className="relative w-full max-w-md border border-border bg-card shadow-2xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="p-6 flex flex-col gap-4">
|
||||
<div>
|
||||
<h2 id="oauth-modal-title" className="font-display text-base tracking-wider uppercase">
|
||||
Connect {provider.name}
|
||||
</h2>
|
||||
{secondsLeft !== null && phase !== "approved" && phase !== "error" && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Session expires in {fmtTime(secondsLeft)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── starting ───────────────────────────────────── */}
|
||||
{phase === "starting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Initiating login flow…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── PKCE: paste code ───────────────────────────── */}
|
||||
{start?.flow === "pkce" && phase === "awaiting_user" && (
|
||||
<>
|
||||
<ol className="text-sm space-y-2 list-decimal list-inside text-muted-foreground">
|
||||
<li>
|
||||
A new tab opened to <code className="text-foreground">claude.ai</code>. Sign in
|
||||
and click <strong className="text-foreground">Authorize</strong>.
|
||||
</li>
|
||||
<li>Copy the <strong className="text-foreground">authorization code</strong> shown after authorizing.</li>
|
||||
<li>Paste it below and submit.</li>
|
||||
</ol>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
value={pkceCode}
|
||||
onChange={(e) => setPkceCode(e.target.value)}
|
||||
placeholder="Paste authorization code (with #state suffix is fine)"
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSubmitPkceCode()}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center gap-2 justify-between">
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "pkce" }>).auth_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open auth page
|
||||
</a>
|
||||
<Button onClick={handleSubmitPkceCode} disabled={!pkceCode.trim()} size="sm">
|
||||
Submit code
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── PKCE: submitting exchange ──────────────────── */}
|
||||
{phase === "submitting" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Exchanging code for tokens…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Device code: show code + URL, polling ──────── */}
|
||||
{start?.flow === "device_code" && phase === "polling" && (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
A new tab opened. Enter this code if prompted:
|
||||
</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}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleCopyUserCode(
|
||||
(start as Extract<OAuthStartResponse, { flow: "device_code" }>).user_code,
|
||||
)
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{codeCopied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||
</Button>
|
||||
</div>
|
||||
<a
|
||||
href={(start as Extract<OAuthStartResponse, { flow: "device_code" }>).verification_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground inline-flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
Re-open verification page
|
||||
</a>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground border-t border-border pt-3">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Waiting for you to authorize in the browser…
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── approved ───────────────────────────────────── */}
|
||||
{phase === "approved" && (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-success">
|
||||
<Check className="h-5 w-5" />
|
||||
Connected! Closing…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── error ──────────────────────────────────────── */}
|
||||
{phase === "error" && (
|
||||
<>
|
||||
<div className="border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{errorMsg || "Login failed."}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Cancel the old session before starting a new one
|
||||
if (start?.session_id) {
|
||||
api.cancelOAuthSession(start.session_id).catch(() => {});
|
||||
}
|
||||
setErrorMsg(null);
|
||||
setStart(null);
|
||||
setPkceCode("");
|
||||
setPhase("starting");
|
||||
// Re-trigger the start effect by remounting (caller should re-key us)
|
||||
// Simpler: just kick off a new start manually
|
||||
api.startOAuthLogin(provider.id).then((resp) => {
|
||||
if (!isMounted.current) return;
|
||||
setStart(resp);
|
||||
setSecondsLeft(resp.expires_in);
|
||||
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
|
||||
if (resp.flow === "pkce") {
|
||||
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
window.open(resp.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
}).catch((e) => {
|
||||
if (!isMounted.current) return;
|
||||
setPhase("error");
|
||||
setErrorMsg(`Retry failed: ${e}`);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
290
web/src/components/OAuthProvidersCard.tsx
Normal file
290
web/src/components/OAuthProvidersCard.tsx
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { ShieldCheck, ShieldOff, Copy, ExternalLink, RefreshCw, LogOut, Terminal, LogIn } from "lucide-react";
|
||||
import { api, type OAuthProvider } from "@/lib/api";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { OAuthLoginModal } from "@/components/OAuthLoginModal";
|
||||
|
||||
/**
|
||||
* OAuthProvidersCard — surfaces every OAuth-capable LLM provider with its
|
||||
* current connection status, a truncated token preview when connected, and
|
||||
* action buttons (Copy CLI command for setup, Disconnect for cleanup).
|
||||
*
|
||||
* Phase 1 scope: read-only status + disconnect + copy-to-clipboard CLI
|
||||
* command. Phase 2 will add in-browser PKCE / device-code flows so users
|
||||
* never need to drop to a terminal.
|
||||
*/
|
||||
|
||||
interface Props {
|
||||
onError?: (msg: string) => void;
|
||||
onSuccess?: (msg: string) => void;
|
||||
}
|
||||
|
||||
const FLOW_LABELS: Record<OAuthProvider["flow"], string> = {
|
||||
pkce: "Browser login (PKCE)",
|
||||
device_code: "Device code",
|
||||
external: "External CLI",
|
||||
};
|
||||
|
||||
function formatExpiresAt(expiresAt: string | null | undefined): string | null {
|
||||
if (!expiresAt) return null;
|
||||
try {
|
||||
const dt = new Date(expiresAt);
|
||||
if (Number.isNaN(dt.getTime())) return null;
|
||||
const now = Date.now();
|
||||
const diff = dt.getTime() - now;
|
||||
if (diff < 0) return "expired";
|
||||
const mins = Math.floor(diff / 60_000);
|
||||
if (mins < 60) return `expires in ${mins}m`;
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return `expires in ${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `expires in ${days}d`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function OAuthProvidersCard({ onError, onSuccess }: Props) {
|
||||
const [providers, setProviders] = useState<OAuthProvider[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
// Provider that the login modal is currently open for. null = modal closed.
|
||||
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
|
||||
|
||||
// Use refs for callbacks to avoid re-creating refresh() when parent re-renders
|
||||
const onErrorRef = useRef(onError);
|
||||
onErrorRef.current = onError;
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getOAuthProviders()
|
||||
.then((resp) => setProviders(resp.providers))
|
||||
.catch((e) => onErrorRef.current?.(`Failed to load providers: ${e}`))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const handleCopy = async (provider: OAuthProvider) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(provider.cli_command);
|
||||
setCopiedId(provider.id);
|
||||
onSuccess?.(`Copied: ${provider.cli_command}`);
|
||||
setTimeout(() => setCopiedId((v) => (v === provider.id ? null : v)), 1500);
|
||||
} catch {
|
||||
onError?.("Clipboard write failed — copy the command manually");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisconnect = async (provider: OAuthProvider) => {
|
||||
if (!confirm(`Disconnect ${provider.name}? You'll need to log in again to use this provider.`)) {
|
||||
return;
|
||||
}
|
||||
setBusyId(provider.id);
|
||||
try {
|
||||
await api.disconnectOAuthProvider(provider.id);
|
||||
onSuccess?.(`${provider.name} disconnected`);
|
||||
refresh();
|
||||
} catch (e) {
|
||||
onError?.(`Disconnect failed: ${e}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const connectedCount = providers?.filter((p) => p.status.logged_in).length ?? 0;
|
||||
const totalCount = providers?.length ?? 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle className="text-base">Provider Logins (OAuth)</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={refresh}
|
||||
disabled={loading}
|
||||
className="text-xs"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 mr-1 ${loading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>
|
||||
{connectedCount} of {totalCount} OAuth providers connected. Login flows currently
|
||||
run via the CLI; click <em>Copy command</em> and paste into a terminal to set up.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading && providers === null && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)}
|
||||
{providers && providers.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">
|
||||
No OAuth-capable providers detected.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{providers?.map((p) => {
|
||||
const expiresLabel = formatExpiresAt(p.status.expires_at);
|
||||
const isBusy = busyId === p.id;
|
||||
return (
|
||||
<div
|
||||
key={p.id}
|
||||
className="flex items-center justify-between gap-4 py-3"
|
||||
>
|
||||
{/* Left: status icon + name + source */}
|
||||
<div className="flex items-start gap-3 min-w-0 flex-1">
|
||||
{p.status.logged_in ? (
|
||||
<ShieldCheck className="h-5 w-5 text-success shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<ShieldOff className="h-5 w-5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex flex-col min-w-0 gap-0.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm">{p.name}</span>
|
||||
<Badge variant="outline" className="text-[11px] uppercase tracking-wide">
|
||||
{FLOW_LABELS[p.flow]}
|
||||
</Badge>
|
||||
{p.status.logged_in && (
|
||||
<Badge variant="success" className="text-[11px]">
|
||||
Connected
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel === "expired" && (
|
||||
<Badge variant="destructive" className="text-[11px]">
|
||||
Expired
|
||||
</Badge>
|
||||
)}
|
||||
{expiresLabel && expiresLabel !== "expired" && (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{expiresLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{p.status.logged_in && p.status.token_preview && (
|
||||
<code className="text-xs text-muted-foreground font-mono-ui truncate">
|
||||
token{" "}
|
||||
<span className="text-foreground">{p.status.token_preview}</span>
|
||||
{p.status.source_label && (
|
||||
<span className="text-muted-foreground/70">
|
||||
{" "}· {p.status.source_label}
|
||||
</span>
|
||||
)}
|
||||
</code>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<span className="text-xs text-muted-foreground/80">
|
||||
Not connected. Run{" "}
|
||||
<code className="text-foreground bg-secondary/40 px-1 rounded">
|
||||
{p.cli_command}
|
||||
</code>{" "}
|
||||
in a terminal.
|
||||
</span>
|
||||
)}
|
||||
{p.status.error && (
|
||||
<span className="text-xs text-destructive">
|
||||
{p.status.error}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Right: action buttons */}
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{p.docs_url && (
|
||||
<a
|
||||
href={p.docs_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex"
|
||||
title={`Open ${p.name} docs`}
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</a>
|
||||
)}
|
||||
{!p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => setLoginFor(p)}
|
||||
className="text-xs h-7"
|
||||
title={`Start ${p.flow === "pkce" ? "browser" : "device code"} login`}
|
||||
>
|
||||
<LogIn className="h-3 w-3 mr-1" />
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
{!p.status.logged_in && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(p)}
|
||||
className="text-xs h-7"
|
||||
title="Copy CLI command (for external / fallback)"
|
||||
>
|
||||
{copiedId === p.id ? (
|
||||
<>Copied ✓</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3 w-3 mr-1" />
|
||||
CLI
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow !== "external" && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDisconnect(p)}
|
||||
disabled={isBusy}
|
||||
className="text-xs h-7"
|
||||
>
|
||||
{isBusy ? (
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-3 w-3 mr-1" />
|
||||
)}
|
||||
Disconnect
|
||||
</Button>
|
||||
)}
|
||||
{p.status.logged_in && p.flow === "external" && (
|
||||
<span className="text-[11px] text-muted-foreground italic px-2">
|
||||
<Terminal className="h-3 w-3 inline mr-0.5" />
|
||||
Managed externally
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
{loginFor && (
|
||||
<OAuthLoginModal
|
||||
provider={loginFor}
|
||||
onClose={() => {
|
||||
setLoginFor(null);
|
||||
refresh(); // always refresh on close so token preview updates after login
|
||||
}}
|
||||
onSuccess={(msg) => onSuccess?.(msg)}
|
||||
onError={(msg) => onError?.(msg)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export function Toast({ toast }: { toast: { message: string; type: "success" | "error" } | null }) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
|
@ -17,11 +18,13 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
|
|||
|
||||
if (!current) return null;
|
||||
|
||||
return (
|
||||
// Portal to document.body so the toast escapes any ancestor stacking context
|
||||
// (e.g. <main> has `relative z-2`, which would trap z-50 below the header's z-40).
|
||||
return createPortal(
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={`fixed top-4 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
className={`fixed top-16 right-4 z-50 border px-4 py-2.5 font-courier text-xs tracking-wider uppercase backdrop-blur-sm ${
|
||||
current.type === "success"
|
||||
? "bg-success/15 text-success border-success/30"
|
||||
: "bg-destructive/15 text-destructive border-destructive/30"
|
||||
|
|
@ -31,6 +34,7 @@ export function Toast({ toast }: { toast: { message: string; type: "success" | "
|
|||
}}
|
||||
>
|
||||
{current.message}
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,8 @@ async function getSessionToken(): Promise<string> {
|
|||
|
||||
export const api = {
|
||||
getStatus: () => fetchJSON<StatusResponse>("/api/status"),
|
||||
getSessions: () => fetchJSON<SessionInfo[]>("/api/sessions"),
|
||||
getSessions: (limit = 20, offset = 0) =>
|
||||
fetchJSON<PaginatedSessions>(`/api/sessions?limit=${limit}&offset=${offset}`),
|
||||
getSessionMessages: (id: string) =>
|
||||
fetchJSON<SessionMessagesResponse>(`/api/sessions/${encodeURIComponent(id)}/messages`),
|
||||
deleteSession: (id: string) =>
|
||||
|
|
@ -110,6 +111,62 @@ export const api = {
|
|||
// Session search (FTS5)
|
||||
searchSessions: (q: string) =>
|
||||
fetchJSON<SessionSearchResponse>(`/api/sessions/search?q=${encodeURIComponent(q)}`),
|
||||
|
||||
// OAuth provider management
|
||||
getOAuthProviders: () =>
|
||||
fetchJSON<OAuthProvidersResponse>("/api/providers/oauth"),
|
||||
disconnectOAuthProvider: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<{ ok: boolean; provider: string }>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
},
|
||||
startOAuthLogin: async (providerId: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthStartResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/start`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: "{}",
|
||||
},
|
||||
);
|
||||
},
|
||||
submitOAuthCode: async (providerId: string, sessionId: string, code: string) => {
|
||||
const token = await getSessionToken();
|
||||
return fetchJSON<OAuthSubmitResponse>(
|
||||
`/api/providers/oauth/${encodeURIComponent(providerId)}/submit`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
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 }>(
|
||||
`/api/providers/oauth/sessions/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export interface PlatformStatus {
|
||||
|
|
@ -152,6 +209,13 @@ export interface SessionInfo {
|
|||
preview: string | null;
|
||||
}
|
||||
|
||||
export interface PaginatedSessions {
|
||||
sessions: SessionInfo[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface EnvVarInfo {
|
||||
is_set: boolean;
|
||||
redacted_value: string | null;
|
||||
|
|
@ -260,3 +324,61 @@ export interface SessionSearchResult {
|
|||
export interface SessionSearchResponse {
|
||||
results: SessionSearchResult[];
|
||||
}
|
||||
|
||||
// ── OAuth provider types ────────────────────────────────────────────────
|
||||
|
||||
export interface OAuthProviderStatus {
|
||||
logged_in: boolean;
|
||||
source?: string | null;
|
||||
source_label?: string | null;
|
||||
token_preview?: string | null;
|
||||
expires_at?: string | null;
|
||||
has_refresh_token?: boolean;
|
||||
last_refresh?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface OAuthProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
/** "pkce" (browser redirect + paste code), "device_code" (show code + URL),
|
||||
* or "external" (delegated to a separate CLI like Claude Code or Qwen). */
|
||||
flow: "pkce" | "device_code" | "external";
|
||||
cli_command: string;
|
||||
docs_url: string;
|
||||
status: OAuthProviderStatus;
|
||||
}
|
||||
|
||||
export interface OAuthProvidersResponse {
|
||||
providers: OAuthProvider[];
|
||||
}
|
||||
|
||||
/** Discriminated union — the shape of /start depends on the flow. */
|
||||
export type OAuthStartResponse =
|
||||
| {
|
||||
session_id: string;
|
||||
flow: "pkce";
|
||||
auth_url: string;
|
||||
expires_in: number;
|
||||
}
|
||||
| {
|
||||
session_id: string;
|
||||
flow: "device_code";
|
||||
user_code: string;
|
||||
verification_url: string;
|
||||
expires_in: number;
|
||||
poll_interval: number;
|
||||
};
|
||||
|
||||
export interface OAuthSubmitResponse {
|
||||
ok: boolean;
|
||||
status: "approved" | "error";
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface OAuthPollResponse {
|
||||
session_id: string;
|
||||
status: "pending" | "approved" | "denied" | "expired" | "error";
|
||||
error_message?: string | null;
|
||||
expires_at?: number | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
<App />,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import {
|
||||
BarChart3,
|
||||
Coins,
|
||||
Cpu,
|
||||
Database,
|
||||
Hash,
|
||||
TrendingUp,
|
||||
} from "lucide-react";
|
||||
|
|
@ -26,17 +24,6 @@ function formatTokens(n: number): string {
|
|||
return String(n);
|
||||
}
|
||||
|
||||
function formatCost(n: number): string {
|
||||
if (n < 0.01) return `$${n.toFixed(4)}`;
|
||||
return `$${n.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** Pick the best cost value: actual > estimated > 0 */
|
||||
function bestCost(entry: { estimated_cost: number; actual_cost?: number }): number {
|
||||
if (entry.actual_cost && entry.actual_cost > 0) return entry.actual_cost;
|
||||
return entry.estimated_cost;
|
||||
}
|
||||
|
||||
function formatDate(day: string): string {
|
||||
try {
|
||||
const d = new Date(day + "T00:00:00");
|
||||
|
|
@ -100,9 +87,6 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
const total = d.input_tokens + d.output_tokens;
|
||||
const inputH = Math.round((d.input_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const outputH = Math.round((d.output_tokens / maxTokens) * CHART_HEIGHT_PX);
|
||||
const cacheReadPct = d.cache_read_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / (d.input_tokens + d.cache_read_tokens)) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={d.day}
|
||||
|
|
@ -115,9 +99,7 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<div className="font-medium">{formatDate(d.day)}</div>
|
||||
<div>Input: {formatTokens(d.input_tokens)}</div>
|
||||
<div>Output: {formatTokens(d.output_tokens)}</div>
|
||||
{cacheReadPct > 0 && <div>Cache hit: {cacheReadPct}%</div>}
|
||||
<div>Total: {formatTokens(total)}</div>
|
||||
{bestCost(d) > 0 && <div>Cost: {formatCost(bestCost(d))}</div>}
|
||||
</div>
|
||||
</div>
|
||||
{/* Input bar */}
|
||||
|
|
@ -168,17 +150,11 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<th className="text-left py-2 pr-4 font-medium">Date</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Input</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Output</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Cache Hit</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Output</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((d) => {
|
||||
const cost = bestCost(d);
|
||||
const cacheHitPct = d.cache_read_tokens > 0 && d.input_tokens > 0
|
||||
? Math.round((d.cache_read_tokens / d.input_tokens) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<tr key={d.day} className="border-b border-border/50 hover:bg-secondary/20 transition-colors">
|
||||
<td className="py-2 pr-4 font-medium">{formatDate(d.day)}</td>
|
||||
|
|
@ -186,15 +162,9 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
|
|||
<td className="text-right py-2 px-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(d.input_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-emerald-400">{formatTokens(d.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">
|
||||
{cacheHitPct > 0 ? `${cacheHitPct}%` : "—"}
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{cost > 0 ? formatCost(cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
|
@ -228,8 +198,7 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<tr className="border-b border-border text-muted-foreground text-xs">
|
||||
<th className="text-left py-2 pr-4 font-medium">Model</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Sessions</th>
|
||||
<th className="text-right py-2 px-4 font-medium">Tokens</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Cost</th>
|
||||
<th className="text-right py-2 pl-4 font-medium">Tokens</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -239,14 +208,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
|
|||
<span className="font-mono-ui text-xs">{m.model}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 px-4 text-muted-foreground">{m.sessions}</td>
|
||||
<td className="text-right py-2 px-4">
|
||||
<td className="text-right py-2 pl-4">
|
||||
<span className="text-[#ffe6cb]">{formatTokens(m.input_tokens)}</span>
|
||||
{" / "}
|
||||
<span className="text-emerald-400">{formatTokens(m.output_tokens)}</span>
|
||||
</td>
|
||||
<td className="text-right py-2 pl-4 text-muted-foreground">
|
||||
{m.estimated_cost > 0 ? formatCost(m.estimated_cost) : "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
|
@ -311,38 +277,26 @@ export default function AnalyticsPage() {
|
|||
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary cards — matches hermes's token model */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<SummaryCard
|
||||
icon={Hash}
|
||||
label="Total Tokens"
|
||||
value={formatTokens(data.totals.total_input + data.totals.total_output)}
|
||||
sub={`${formatTokens(data.totals.total_input)} in / ${formatTokens(data.totals.total_output)} out`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Database}
|
||||
label="Cache Hit"
|
||||
value={data.totals.total_cache_read > 0
|
||||
? `${Math.round((data.totals.total_cache_read / (data.totals.total_input + data.totals.total_cache_read)) * 100)}%`
|
||||
: "—"}
|
||||
sub={`${formatTokens(data.totals.total_cache_read)} tokens from cache`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={Coins}
|
||||
label="Total Cost"
|
||||
value={formatCost(
|
||||
data.totals.total_actual_cost > 0
|
||||
? data.totals.total_actual_cost
|
||||
: data.totals.total_estimated_cost
|
||||
)}
|
||||
sub={data.totals.total_actual_cost > 0 ? "actual" : `estimated · last ${days}d`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={BarChart3}
|
||||
label="Total Sessions"
|
||||
value={String(data.totals.total_sessions)}
|
||||
sub={`~${(data.totals.total_sessions / days).toFixed(1)}/day avg`}
|
||||
/>
|
||||
<SummaryCard
|
||||
icon={TrendingUp}
|
||||
label="API Calls"
|
||||
value={String(data.daily.reduce((sum, d) => sum + d.sessions, 0))}
|
||||
sub={`across ${data.by_model.length} models`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bar chart */}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { api } from "@/lib/api";
|
|||
import type { EnvVarInfo } from "@/lib/api";
|
||||
import { useToast } from "@/hooks/useToast";
|
||||
import { Toast } from "@/components/Toast";
|
||||
import { OAuthProvidersCard } from "@/components/OAuthProvidersCard";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -495,6 +496,12 @@ export default function EnvPage() {
|
|||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ═══════════════ OAuth Logins (sits above API keys — distinct auth mode) ══ */}
|
||||
<OAuthProvidersCard
|
||||
onError={(msg) => showToast(msg, "error")}
|
||||
onSuccess={(msg) => showToast(msg, "success")}
|
||||
/>
|
||||
|
||||
{/* ═══════════════ LLM Providers (grouped) ═══════════════ */}
|
||||
<Card>
|
||||
<CardHeader className="sticky top-14 z-10 bg-card border-b border-border">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
MessageSquare,
|
||||
Search,
|
||||
|
|
@ -287,6 +288,9 @@ function SessionRow({
|
|||
|
||||
export default function SessionsPage() {
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(0);
|
||||
const PAGE_SIZE = 20;
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
|
@ -294,17 +298,21 @@ export default function SessionsPage() {
|
|||
const [searching, setSearching] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
|
||||
|
||||
const loadSessions = useCallback(() => {
|
||||
const loadSessions = useCallback((p: number) => {
|
||||
setLoading(true);
|
||||
api
|
||||
.getSessions()
|
||||
.then(setSessions)
|
||||
.getSessions(PAGE_SIZE, p * PAGE_SIZE)
|
||||
.then((resp) => {
|
||||
setSessions(resp.sessions);
|
||||
setTotal(resp.total);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
loadSessions(page);
|
||||
}, [loadSessions, page]);
|
||||
|
||||
// Debounced FTS search
|
||||
useEffect(() => {
|
||||
|
|
@ -334,6 +342,7 @@ export default function SessionsPage() {
|
|||
try {
|
||||
await api.deleteSession(id);
|
||||
setSessions((prev) => prev.filter((s) => s.id !== id));
|
||||
setTotal((prev) => prev - 1);
|
||||
if (expandedId === id) setExpandedId(null);
|
||||
} catch {
|
||||
// ignore
|
||||
|
|
@ -370,7 +379,7 @@ export default function SessionsPage() {
|
|||
<MessageSquare className="h-5 w-5 text-muted-foreground" />
|
||||
<h1 className="text-base font-semibold">Sessions</h1>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{sessions.length}
|
||||
{total}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative w-64">
|
||||
|
|
@ -408,21 +417,57 @@ export default function SessionsPage() {
|
|||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{filtered.map((s) => (
|
||||
<SessionRow
|
||||
key={s.id}
|
||||
session={s}
|
||||
snippet={snippetMap.get(s.id)}
|
||||
searchQuery={search || undefined}
|
||||
isExpanded={expandedId === s.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === s.id ? null : s.id))
|
||||
}
|
||||
onDelete={() => handleDelete(s.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination — hidden during search */}
|
||||
{!searchResults && total > PAGE_SIZE && (
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, total)} of {total}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={page === 0}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-2">
|
||||
Page {page + 1} of {Math.ceil(total / PAGE_SIZE)}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
disabled={(page + 1) * PAGE_SIZE >= total}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export default function StatusPage() {
|
|||
useEffect(() => {
|
||||
const load = () => {
|
||||
api.getStatus().then(setStatus).catch(() => {});
|
||||
api.getSessions().then(setSessions).catch(() => {});
|
||||
api.getSessions(50).then((resp) => setSessions(resp.sessions)).catch(() => {});
|
||||
};
|
||||
load();
|
||||
const interval = setInterval(load, 5000);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue