Compare commits

...

2 Commits

Author SHA1 Message Date
Austin Pickett
290acdb59c fix(auth): address PR review comments for Google Workspace OAuth
- Secure token file permissions (0o600) in dashboard callback handler
- Validate refresh_token presence after code exchange
- HTML-escape all dynamic values in callback pages (XSS prevention)
- Raise error when only placeholder credentials are available
- Fix docstring to match actual behavior (no standalone fallback)
- Validate OAuth state parameter in headless mode
- Reduce client_id log exposure to 8 chars
- Use configurable port for dashboard redirect URI (app.state.bound_port)
- Read HERMES_DASHBOARD_PORT env var instead of hardcoding 9119
2026-05-08 13:35:41 -04:00
Austin Pickett
b9d541ecb8 feat(auth): add integrated Google Workspace OAuth provider
- Add agent/google_workspace_oauth.py: PKCE OAuth module with bundled
  Nous client ID (placeholder), local fallback server, dashboard
  integration, headless mode, token refresh, and revocation
- Add 'hermes auth google-workspace login/status/logout' CLI commands
- Add 'hermes auth add google-workspace' redirect to login flow
- Add Google Workspace to dashboard OAuth providers card with
  server-side callback at /auth/google/callback
- Dashboard PKCE flow: auto-redirect callback, session polling,
  auto-close modal on success
- Branded callback pages (dark teal theme matching dashboard)
- Disconnect uses in-app modal instead of browser alert dialog
- CLI delegates to dashboard when running (single source of truth)
- Falls back to headless mode with --no-browser when dashboard is down
- Middleware bypass for google-workspace start/poll endpoints (CLI access)
2026-05-08 13:01:03 -04:00
7 changed files with 1771 additions and 8 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -112,6 +112,7 @@ DEFAULT_SPOTIFY_SCOPE = " ".join((
))
SERVICE_PROVIDER_NAMES: Dict[str, str] = {
"spotify": "Spotify",
"google-workspace": "Google Workspace",
}
# Google Gemini OAuth (google-gemini-cli provider, Cloud Code Assist backend)
@@ -1804,6 +1805,138 @@ def get_gemini_oauth_auth_status() -> Dict[str, Any]:
"email": creds.email,
"project_id": creds.project_id,
}
# =============================================================================
# Google Workspace OAuth — PKCE flow for Gmail, Calendar, Drive, Sheets, Docs.
#
# Tokens live in ~/.hermes/google_token.json (same file the google-workspace
# skill reads). This is a *service* provider, not an inference provider —
# it gives the agent access to the user's personal Google data.
# =============================================================================
def get_google_workspace_auth_status() -> Dict[str, Any]:
"""Return status dict for `hermes auth status google-workspace`."""
try:
from agent.google_workspace_oauth import get_auth_status as _gws_status
except ImportError:
return {"logged_in": False, "error": "agent.google_workspace_oauth unavailable"}
return _gws_status()
def login_google_workspace_command(args) -> None:
"""Run interactive Google Workspace PKCE login."""
try:
from agent.google_workspace_oauth import (
run_oauth_login,
run_oauth_login_headless,
exchange_code,
credentials_path,
get_client_credentials,
GoogleWorkspaceOAuthError,
)
except ImportError as exc:
raise SystemExit(f"Google Workspace OAuth module not available: {exc}")
open_browser = not getattr(args, "no_browser", False)
# Check if we can resolve client credentials at all
try:
client_id, _ = get_client_credentials()
except GoogleWorkspaceOAuthError as exc:
print(f"Error: {exc}")
print()
print("To use Google Workspace, provide OAuth client credentials via one of:")
print(" 1. Set HERMES_GOOGLE_WORKSPACE_CLIENT_ID and HERMES_GOOGLE_WORKSPACE_CLIENT_SECRET env vars")
print(" 2. Place a google_client_secret.json in ~/.hermes/")
print(" 3. Wait for the bundled Nous Research app (coming soon)")
raise SystemExit(1)
if _is_remote_session() or not open_browser:
# Headless flow — print URL for user to visit manually
auth_url, state, code_verifier = run_oauth_login_headless()
print("Google Workspace PKCE login (headless mode)")
print()
print("Open this URL in your browser:")
print(auth_url)
print()
print("After authorizing, you'll be redirected to a page that may show an error.")
print("Copy the ENTIRE URL from your browser's address bar and paste it here:")
print()
try:
callback_input = input("Paste URL or code: ").strip()
except (KeyboardInterrupt, EOFError):
print("\nAborted.")
raise SystemExit(1)
if not callback_input:
print("No input provided.")
raise SystemExit(1)
# Extract code from URL if user pasted the full redirect URL
code = callback_input
returned_state = None
if callback_input.startswith("http"):
from urllib.parse import parse_qs, urlparse
parsed = urlparse(callback_input)
params = parse_qs(parsed.query)
if "error" in params:
print(f"Error from Google: {params['error'][0]}")
raise SystemExit(1)
if "code" not in params:
print("Error: No 'code' parameter found in the pasted URL.")
raise SystemExit(1)
code = params["code"][0]
returned_state = params.get("state", [None])[0]
# Validate state if available
if returned_state and returned_state != state:
print("Error: OAuth state mismatch. This may be a code from a different session.")
raise SystemExit(1)
try:
token_data = exchange_code(code, state, code_verifier, redirect_uri="http://localhost:1")
except GoogleWorkspaceOAuthError as exc:
print(f"Error: {exc}")
raise SystemExit(1)
else:
# Interactive flow — opens browser, waits for callback
print("Starting Google Workspace PKCE login...")
print(f" Client ID: {client_id[:8]}")
print(f" Token will be saved to: {credentials_path()}")
print()
try:
token_data = run_oauth_login()
except GoogleWorkspaceOAuthError as exc:
print(f"Error: {exc}")
raise SystemExit(1)
print()
print("✓ Google Workspace login successful!")
print(f" Token saved to: {credentials_path()}")
scopes = token_data.get("scopes", [])
if scopes:
print(f" Scopes: {len(scopes)} granted")
def logout_google_workspace_command(args) -> None:
"""Revoke and clear Google Workspace credentials."""
try:
from agent.google_workspace_oauth import revoke, credentials_path
except ImportError as exc:
raise SystemExit(f"Google Workspace OAuth module not available: {exc}")
token_path = credentials_path()
if not token_path.exists():
print("Google Workspace: not logged in (no token file).")
return
success = revoke()
if success:
print("✓ Google Workspace: logged out and token revoked.")
else:
print("Google Workspace: token file removed (remote revocation may have failed).")
# Spotify auth — PKCE tokens stored in ~/.hermes/auth.json
# =============================================================================
@@ -4027,6 +4160,8 @@ def get_auth_status(provider_id: Optional[str] = None) -> Dict[str, Any]:
target = provider_id or get_active_provider()
if target == "spotify":
return get_spotify_auth_status()
if target == "google-workspace":
return get_google_workspace_auth_status()
if target == "nous":
return get_nous_auth_status()
if target == "openai-codex":

View File

@@ -160,6 +160,15 @@ def _format_exhausted_status(entry) -> str:
def auth_add_command(args) -> None:
provider = _normalize_provider(getattr(args, "provider", ""))
# Service providers (not inference) — redirect to their dedicated login flow
if provider == "google-workspace":
auth_mod.login_google_workspace_command(args)
return
if provider == "spotify":
auth_mod.login_spotify_command(args)
return
if provider not in PROVIDER_REGISTRY and provider != "openrouter" and not provider.startswith(CUSTOM_POOL_PREFIX):
raise SystemExit(f"Unknown provider: {provider}")
@@ -508,6 +517,20 @@ def auth_spotify_command(args) -> None:
raise SystemExit(f"Unknown Spotify auth action: {action}")
def auth_google_workspace_command(args) -> None:
action = str(getattr(args, "gws_action", "") or "login").strip().lower()
if action in {"", "login"}:
auth_mod.login_google_workspace_command(args)
return
if action == "status":
auth_status_command(SimpleNamespace(provider="google-workspace"))
return
if action == "logout":
auth_mod.logout_google_workspace_command(args)
return
raise SystemExit(f"Unknown Google Workspace auth action: {action}")
def _interactive_auth() -> None:
"""Interactive credential pool management when `hermes auth` is called bare."""
# Show current pool status first
@@ -714,5 +737,8 @@ def auth_command(args) -> None:
if action == "spotify":
auth_spotify_command(args)
return
if action == "google-workspace":
auth_google_workspace_command(args)
return
# No subcommand — launch interactive mode
_interactive_auth()

View File

@@ -9032,6 +9032,22 @@ def main():
auth_spotify.add_argument(
"--timeout", type=float, help="Callback/token exchange timeout in seconds"
)
auth_gws = auth_subparsers.add_parser(
"google-workspace", help="Authenticate Hermes with Google Workspace (Gmail, Calendar, Drive, Sheets, Docs)"
)
auth_gws.add_argument(
"gws_action",
nargs="?",
choices=["login", "status", "logout"],
default="login",
)
auth_gws.add_argument(
"--no-browser",
action="store_true",
help="Do not attempt to open the browser automatically (headless mode)",
)
auth_parser.set_defaults(func=cmd_auth)
# =========================================================================

View File

@@ -10,6 +10,9 @@ Usage:
"""
import asyncio
import base64
import hashlib
import html
import hmac
import importlib.util
import json
@@ -226,6 +229,9 @@ async def auth_middleware(request: Request, call_next):
"""Require the session token on all /api/ routes except the public list."""
path = request.url.path
if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"):
# Google Workspace OAuth start + poll are safe without auth (CLI access)
if path == "/api/providers/oauth/google-workspace/start" or path.startswith("/api/providers/oauth/google-workspace/poll/"):
return await call_next(request)
if not _has_valid_session_token(request):
return JSONResponse(
status_code=401,
@@ -1462,6 +1468,14 @@ _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = (
"docs_url": "https://www.minimax.io",
"status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status
},
{
"id": "google-workspace",
"name": "Google Workspace",
"flow": "pkce",
"cli_command": "hermes auth google-workspace login",
"docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/features/google-workspace",
"status_fn": None, # dispatched via auth.get_google_workspace_auth_status
},
)
@@ -1515,6 +1529,16 @@ def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]:
"expires_at": raw.get("expires_at"),
"has_refresh_token": True,
}
if provider_id == "google-workspace":
raw = hauth.get_google_workspace_auth_status()
return {
"logged_in": bool(raw.get("logged_in")),
"source": "google_workspace_oauth",
"source_label": raw.get("email") or "Google Workspace",
"token_preview": None,
"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}
@@ -1585,6 +1609,15 @@ async def disconnect_oauth_provider(provider_id: str, request: Request):
_log.info("oauth/disconnect: %s", provider_id)
return {"ok": True, "provider": provider_id}
if provider_id == "google-workspace":
try:
from agent.google_workspace_oauth import revoke
revoke()
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)
@@ -2097,10 +2130,167 @@ def _codex_full_login_worker(session_id: str) -> None:
s["error_message"] = str(e)
def _start_google_workspace_pkce() -> Dict[str, Any]:
"""Begin Google Workspace PKCE flow. Returns auth URL for the dashboard to open."""
try:
from agent.google_workspace_oauth import (
get_client_credentials,
GoogleWorkspaceOAuthError,
AUTH_ENDPOINT,
SCOPES,
)
except ImportError:
raise HTTPException(status_code=501, detail="Google Workspace OAuth module not available")
try:
client_id, client_secret = get_client_credentials()
except GoogleWorkspaceOAuthError as exc:
raise HTTPException(status_code=500, detail=str(exc))
# Generate PKCE pair
verifier = secrets.token_urlsafe(64)
challenge_bytes = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b"=").decode("ascii")
sid, sess = _new_oauth_session("google-workspace", "pkce")
sess["verifier"] = verifier
sess["client_id"] = client_id
sess["client_secret"] = client_secret
# Use localhost:9119 callback so the dashboard catches the redirect automatically
# Use the actual bound port if available, default to 9119
bound_port = getattr(app.state, "bound_port", 9119)
redirect_uri = f"http://localhost:{bound_port}/auth/google/callback"
sess["redirect_uri"] = redirect_uri
params = {
"client_id": client_id,
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": " ".join(SCOPES),
"state": sid, # Use session_id as state for easy lookup on callback
"code_challenge": challenge,
"code_challenge_method": "S256",
"access_type": "offline",
"prompt": "consent",
}
auth_url = AUTH_ENDPOINT + "?" + urllib.parse.urlencode(params)
return {
"session_id": sid,
"flow": "pkce",
"auth_url": auth_url,
"expires_in": _OAUTH_SESSION_TTL_SECONDS,
}
def _submit_google_workspace_pkce(session_id: str, code_input: str) -> Dict[str, Any]:
"""Exchange Google Workspace authorization code for tokens. Saves on success."""
with _oauth_sessions_lock:
sess = _oauth_sessions.get(session_id)
if not sess or sess["provider"] != "google-workspace" 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")}
code = code_input.strip()
if not code:
return {"ok": False, "status": "error", "message": "No code provided"}
# Exchange code for tokens
try:
from agent.google_workspace_oauth import (
TOKEN_ENDPOINT,
SCOPES,
credentials_path,
)
except ImportError:
with _oauth_sessions_lock:
sess["status"] = "error"
sess["error_message"] = "Google Workspace OAuth module not available"
return {"ok": False, "status": "error", "message": sess["error_message"]}
exchange_data = urllib.parse.urlencode({
"grant_type": "authorization_code",
"client_id": sess["client_id"],
"client_secret": sess["client_secret"],
"code": code,
"redirect_uri": sess["redirect_uri"],
"code_verifier": sess["verifier"],
}).encode()
req = urllib.request.Request(
TOKEN_ENDPOINT,
data=exchange_data,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"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:
with _oauth_sessions_lock:
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:
with _oauth_sessions_lock:
sess["status"] = "error"
sess["error_message"] = "No access token returned"
return {"ok": False, "status": "error", "message": sess["error_message"]}
if not refresh_token:
with _oauth_sessions_lock:
sess["status"] = "error"
sess["error_message"] = "No refresh token returned. Ensure 'access_type=offline' and 'prompt=consent' are set."
return {"ok": False, "status": "error", "message": sess["error_message"]}
# Save token in google_token.json format (compatible with google-workspace skill)
from datetime import datetime, timezone
expiry = datetime.fromtimestamp(
datetime.now(timezone.utc).timestamp() + expires_in,
tz=timezone.utc,
).isoformat()
token_payload = {
"token": access_token,
"refresh_token": refresh_token,
"token_uri": TOKEN_ENDPOINT,
"client_id": sess["client_id"],
"client_secret": sess["client_secret"],
"scopes": SCOPES,
"expiry": expiry,
"type": "authorized_user",
}
token_path = credentials_path()
token_path.parent.mkdir(parents=True, exist_ok=True)
import os as _os
fd = _os.open(str(token_path), _os.O_WRONLY | _os.O_CREAT | _os.O_TRUNC, 0o600)
try:
_os.write(fd, json.dumps(token_payload, indent=2).encode("utf-8"))
finally:
_os.close(fd)
with _oauth_sessions_lock:
sess["status"] = "approved"
_log.info("oauth/pkce: google-workspace login completed (session=%s)", session_id)
return {"ok": True, "status": "approved"}
@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."""
_require_token(request)
"""Initiate an OAuth login flow. Token-protected (except google-workspace for CLI access)."""
# Google Workspace start is safe without auth — it only generates a consent URL.
# This lets the CLI call it without knowing the ephemeral dashboard token.
if provider_id != "google-workspace":
_require_token(request)
_gc_oauth_sessions()
valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG}
if provider_id not in valid:
@@ -2113,6 +2303,11 @@ async def start_oauth_login(provider_id: str, request: Request):
)
try:
if catalog_entry["flow"] == "pkce":
if provider_id == "anthropic":
return _start_anthropic_pkce()
if provider_id == "google-workspace":
return _start_google_workspace_pkce()
# Fallback (minimax, etc.) — uses anthropic-style PKCE
return _start_anthropic_pkce()
if catalog_entry["flow"] == "device_code":
return await _start_device_code_flow(provider_id)
@@ -2137,6 +2332,10 @@ async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Re
return await asyncio.get_event_loop().run_in_executor(
None, _submit_anthropic_pkce, body.session_id, body.code,
)
if provider_id == "google-workspace":
return await asyncio.get_event_loop().run_in_executor(
None, _submit_google_workspace_pkce, body.session_id, body.code,
)
raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}")
@@ -2157,6 +2356,160 @@ async def poll_oauth_session(provider_id: str, session_id: str):
}
@app.get("/auth/google/callback")
async def google_oauth_callback(request: Request):
"""Handle the Google OAuth redirect.
Google redirects here after the user authorizes. We extract the code,
exchange it for tokens via the matching session, and return an HTML page
that says "Connected! You can close this tab."
"""
from starlette.responses import HTMLResponse
params = dict(request.query_params)
code = params.get("code", "")
state = params.get("state", "") # This is the session_id
error = params.get("error", "")
if error:
return HTMLResponse(
f"""<html>
<head><meta charset="utf-8"><title>Authorization Failed — Hermes Agent</title>
<style>
*{{margin:0;padding:0;box-sizing:border-box}}
body{{background:#041c1c;color:#ffe6cb;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem}}
.card{{border:1px solid rgba(255,100,100,0.3);background:rgba(255,100,100,0.05);padding:3rem 4rem;text-align:center;max-width:480px}}
.icon{{font-size:3rem;margin-bottom:1rem}}
h1{{font-size:1.5rem;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:0.75rem}}
p{{color:rgba(255,230,203,0.7);font-size:0.95rem;line-height:1.6}}
</style></head>
<body><div class="card"><div class="icon">✗</div><h1>Authorization Failed</h1><p>{html.escape(error)}</p></div></body>
</html>""",
status_code=400,
)
if not code or not state:
return HTMLResponse(
"""<html>
<head><meta charset="utf-8"><title>Error — Hermes Agent</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#041c1c;color:#ffe6cb;font-family:system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;display:flex;align-items:center;justify-content:center;min-height:100vh;padding:2rem}
.card{border:1px solid rgba(255,100,100,0.3);background:rgba(255,100,100,0.05);padding:3rem 4rem;text-align:center;max-width:480px}
.icon{font-size:3rem;margin-bottom:1rem}
h1{font-size:1.5rem;font-weight:600;letter-spacing:0.05em;text-transform:uppercase;margin-bottom:0.75rem}
p{color:rgba(255,230,203,0.7);font-size:0.95rem;line-height:1.6}
</style></head>
<body><div class="card"><div class="icon">✗</div><h1>Error</h1><p>Missing code or state parameter.</p></div></body>
</html>""",
status_code=400,
)
# Exchange the code using the session
try:
result = _submit_google_workspace_pkce(state, code)
except HTTPException as exc:
return HTMLResponse(
f"<html><body><h2>❌ {html.escape(str(exc.detail))}</h2></body></html>",
status_code=exc.status_code,
)
if result.get("ok"):
return HTMLResponse(
"""<html>
<head>
<meta charset="utf-8">
<title>Connected — Hermes Agent</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #041c1c;
color: #ffe6cb;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}
.card {
border: 1px solid rgba(255, 230, 203, 0.15);
background: rgba(255, 230, 203, 0.03);
padding: 3rem 4rem;
text-align: center;
max-width: 480px;
}
.icon { font-size: 3rem; margin-bottom: 1rem; }
h1 {
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 0.75rem;
}
p { color: rgba(255, 230, 203, 0.7); font-size: 0.95rem; line-height: 1.6; }
.hint { margin-top: 1.5rem; font-size: 0.85rem; color: rgba(255, 230, 203, 0.4); }
</style>
</head>
<body>
<div class="card">
<div class="icon">✓</div>
<h1>Connected</h1>
<p>Google Workspace is now connected to Hermes Agent.</p>
<p class="hint">You can close this tab.</p>
</div>
</body>
</html>"""
)
else:
msg = result.get("message", "Unknown error")
return HTMLResponse(
f"""<html>
<head>
<meta charset="utf-8">
<title>Connection Failed — Hermes Agent</title>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
background: #041c1c;
color: #ffe6cb;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 2rem;
}}
.card {{
border: 1px solid rgba(255, 100, 100, 0.3);
background: rgba(255, 100, 100, 0.05);
padding: 3rem 4rem;
text-align: center;
max-width: 480px;
}}
.icon {{ font-size: 3rem; margin-bottom: 1rem; }}
h1 {{
font-size: 1.5rem;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
margin-bottom: 0.75rem;
}}
p {{ color: rgba(255, 230, 203, 0.7); font-size: 0.95rem; line-height: 1.6; }}
</style>
</head>
<body>
<div class="card">
<div class="icon">✗</div>
<h1>Connection Failed</h1>
<p>{html.escape(str(msg))}</p>
</div>
</body>
</html>""",
status_code=400,
)
@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."""

View File

@@ -43,7 +43,11 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
if (!isMounted.current) return;
setStart(resp);
setSecondsLeft(resp.expires_in);
setPhase(resp.flow === "device_code" ? "polling" : "awaiting_user");
// Google Workspace PKCE uses a server-side callback — poll like device_code
const usePolling =
resp.flow === "device_code" ||
(resp.flow === "pkce" && provider.id === "google-workspace");
setPhase(usePolling ? "polling" : "awaiting_user");
if (resp.flow === "pkce") {
window.open(resp.auth_url, "_blank", "noopener,noreferrer");
} else {
@@ -80,9 +84,12 @@ export function OAuthLoginModal({ provider, onClose, onSuccess }: Props) {
return () => window.clearInterval(tick);
}, [secondsLeft, phase, t]);
// Device-code: poll backend every 2s
// Device-code or server-callback PKCE (Google Workspace): poll backend every 2s
useEffect(() => {
if (!start || start.flow !== "device_code" || phase !== "polling") return;
if (!start || phase !== "polling") return;
const isDeviceCode = start.flow === "device_code";
const isCallbackPkce = start.flow === "pkce" && provider.id === "google-workspace";
if (!isDeviceCode && !isCallbackPkce) return;
const sid = start.session_id;
pollTimer.current = window.setInterval(async () => {
try {

View File

@@ -55,6 +55,7 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [loginFor, setLoginFor] = useState<OAuthProvider | null>(null);
const [disconnectFor, setDisconnectFor] = useState<OAuthProvider | null>(null);
const { t } = useI18n();
const onErrorRef = useRef(onError);
@@ -74,9 +75,13 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
}, [refresh]);
const handleDisconnect = async (provider: OAuthProvider) => {
if (!confirm(`${t.oauth.disconnect} ${provider.name}?`)) {
return;
}
setDisconnectFor(provider);
};
const confirmDisconnect = async () => {
if (!disconnectFor) return;
const provider = disconnectFor;
setDisconnectFor(null);
setBusyId(provider.id);
try {
await api.disconnectOAuthProvider(provider.id);
@@ -266,6 +271,39 @@ export function OAuthProvidersCard({ onError, onSuccess }: Props) {
onError={(msg) => onError?.(msg)}
/>
)}
{disconnectFor && (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-background/85 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) setDisconnectFor(null); }}
role="dialog"
aria-modal="true"
>
<div className="relative w-full max-w-sm border border-border bg-card shadow-2xl p-6">
<div className="flex flex-col gap-4">
<h2 className="text-lg font-semibold uppercase tracking-wide">
{t.oauth.disconnect}
</h2>
<p className="text-sm text-muted-foreground">
Disconnect <strong>{disconnectFor.name}</strong> from Hermes Agent? You can reconnect anytime.
</p>
<div className="flex gap-3 justify-end pt-2">
<Button
ghost
onClick={() => setDisconnectFor(null)}
>
Cancel
</Button>
<Button
onClick={confirmDisconnect}
className="bg-red-900/50 border-red-700/50 hover:bg-red-800/60 text-red-200"
>
Disconnect
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
);
}