feat: add Gemini auth check to token warning banner
/auth/status now returns per-backend status: Claude warns on <24h expiry, Gemini warns only when oauth_creds.json is missing or has no refresh_token (access token rotates automatically so expiry_date is not a useful signal). Banner shows warnings for both backends when needed, and the hint text names the specific CLI commands to run. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Claude CLI OAuth token status.
|
CLI auth status for both Claude and Gemini backends.
|
||||||
|
|
||||||
GET /auth/status — returns expiry info; warns when < WARN_HOURS remain
|
GET /auth/status — returns per-backend auth info and warning flags
|
||||||
|
|
||||||
|
Claude: warns when OAuth token is < WARN_HOURS from expiry (requires
|
||||||
|
user to re-run `claude` to refresh via browser flow).
|
||||||
|
Gemini: warns only when oauth_creds.json is missing or has no
|
||||||
|
refresh_token (access token rotates automatically every ~1h).
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
@@ -12,17 +17,17 @@ from fastapi import APIRouter
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/auth")
|
router = APIRouter(prefix="/auth")
|
||||||
|
|
||||||
CREDENTIALS_PATH = Path.home() / ".claude" / ".credentials.json"
|
CLAUDE_CREDS = Path.home() / ".claude" / ".credentials.json"
|
||||||
WARN_HOURS = 24 # show warning banner when fewer than this many hours remain
|
GEMINI_CREDS = Path.home() / ".gemini" / "oauth_creds.json"
|
||||||
|
GEMINI_ACCTS = Path.home() / ".gemini" / "google_accounts.json"
|
||||||
|
WARN_HOURS = 24
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status")
|
def _claude_status() -> dict:
|
||||||
async def auth_status() -> dict:
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(CREDENTIALS_PATH.read_text())
|
data = json.loads(CLAUDE_CREDS.read_text())
|
||||||
oauth = data["claudeAiOauth"]
|
oauth = data["claudeAiOauth"]
|
||||||
expires_at_ms = oauth["expiresAt"]
|
expires_dt = datetime.fromtimestamp(oauth["expiresAt"] / 1000, tz=timezone.utc)
|
||||||
expires_dt = datetime.fromtimestamp(expires_at_ms / 1000, tz=timezone.utc)
|
|
||||||
now = datetime.now(tz=timezone.utc)
|
now = datetime.now(tz=timezone.utc)
|
||||||
hours_remaining = (expires_dt - now).total_seconds() / 3600
|
hours_remaining = (expires_dt - now).total_seconds() / 3600
|
||||||
return {
|
return {
|
||||||
@@ -33,5 +38,32 @@ async def auth_status() -> dict:
|
|||||||
"expired": hours_remaining <= 0,
|
"expired": hours_remaining <= 0,
|
||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("auth status check failed: %s", e)
|
logger.warning("claude auth check failed: %s", e)
|
||||||
return {"ok": False, "error": str(e), "warning": True, "expired": False}
|
return {"ok": False, "error": str(e), "warning": True, "expired": False}
|
||||||
|
|
||||||
|
|
||||||
|
def _gemini_status() -> dict:
|
||||||
|
try:
|
||||||
|
creds = json.loads(GEMINI_CREDS.read_text())
|
||||||
|
if not creds.get("refresh_token"):
|
||||||
|
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||||
|
account = None
|
||||||
|
try:
|
||||||
|
accts = json.loads(GEMINI_ACCTS.read_text())
|
||||||
|
account = accts.get("active")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": True, "authenticated": True, "warning": False, "account": account}
|
||||||
|
except FileNotFoundError:
|
||||||
|
return {"ok": True, "authenticated": False, "warning": True, "account": None}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("gemini auth check failed: %s", e)
|
||||||
|
return {"ok": False, "error": str(e), "warning": True, "authenticated": False}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status")
|
||||||
|
async def auth_status() -> dict:
|
||||||
|
return {
|
||||||
|
"claude": _claude_status(),
|
||||||
|
"gemini": _gemini_status(),
|
||||||
|
}
|
||||||
|
|||||||
@@ -967,6 +967,7 @@
|
|||||||
// ── Auth token warning banner ─────────────────────────────
|
// ── Auth token warning banner ─────────────────────────────
|
||||||
const authBanner = document.getElementById('auth-banner');
|
const authBanner = document.getElementById('auth-banner');
|
||||||
const authBannerMsg = document.getElementById('auth-banner-msg');
|
const authBannerMsg = document.getElementById('auth-banner-msg');
|
||||||
|
const authBannerHint = document.getElementById('auth-banner-hint');
|
||||||
const authBannerClose = document.getElementById('auth-banner-close');
|
const authBannerClose = document.getElementById('auth-banner-close');
|
||||||
|
|
||||||
async function checkAuthStatus() {
|
async function checkAuthStatus() {
|
||||||
@@ -974,13 +975,36 @@
|
|||||||
const res = await fetch('/auth/status');
|
const res = await fetch('/auth/status');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const d = await res.json();
|
const d = await res.json();
|
||||||
if (!d.warning) return;
|
|
||||||
|
|
||||||
const msg = d.expired
|
const warnings = [];
|
||||||
? '✕ Claude CLI token has expired'
|
const fixes = [];
|
||||||
: `⚠ Claude CLI token expires in ${d.hours_remaining}h`;
|
let anyExpired = false;
|
||||||
authBannerMsg.textContent = msg;
|
|
||||||
authBanner.classList.toggle('expired', !!d.expired);
|
if (d.claude?.warning) {
|
||||||
|
if (d.claude.expired) {
|
||||||
|
warnings.push('✕ Claude CLI token has expired');
|
||||||
|
anyExpired = true;
|
||||||
|
} else {
|
||||||
|
warnings.push(`⚠ Claude CLI token expires in ${d.claude.hours_remaining}h`);
|
||||||
|
}
|
||||||
|
fixes.push('<code>claude</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.gemini?.warning) {
|
||||||
|
warnings.push('⚠ Gemini CLI not authenticated');
|
||||||
|
fixes.push('<code>gemini</code>');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!warnings.length) {
|
||||||
|
authBanner.classList.remove('show');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
authBannerMsg.innerHTML = warnings.join('<br>');
|
||||||
|
authBannerHint.innerHTML =
|
||||||
|
`To fix: SSH into the Cortex host and run ${fixes.join(' and/or ')} — `
|
||||||
|
+ 'follow the login prompt, then restart Cortex.';
|
||||||
|
authBanner.classList.toggle('expired', anyExpired);
|
||||||
authBanner.classList.add('show');
|
authBanner.classList.add('show');
|
||||||
} catch { /* silently ignore — don't break the UI */ }
|
} catch { /* silently ignore — don't break the UI */ }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,7 +113,7 @@
|
|||||||
<div id="auth-banner">
|
<div id="auth-banner">
|
||||||
<div id="auth-banner-text">
|
<div id="auth-banner-text">
|
||||||
<span id="auth-banner-msg"></span>
|
<span id="auth-banner-msg"></span>
|
||||||
<span id="auth-banner-hint">To fix: SSH into the Cortex host and run <code>claude</code> — follow the login prompt, then restart Cortex.</span>
|
<span id="auth-banner-hint"></span>
|
||||||
</div>
|
</div>
|
||||||
<button id="auth-banner-close" title="Dismiss">✕</button>
|
<button id="auth-banner-close" title="Dismiss">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user