From 4137813fc785559235311223e17d9f4a18514ab4 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Fri, 24 Jul 2026 20:43:03 +0000 Subject: [PATCH] Use central Anthropic key for AI insights; add update-check banner AI insights now fetches the Claude API key from the central settings service instead of storing its own encrypted copy, so wages and other apps can share the same key. Also wires up the update-available banner using the existing version-check hook pattern. Co-Authored-By: Claude Sonnet 5 --- backend/api/config.py | 28 +++++++-------- backend/jobs/ai_insights.py | 8 +++-- backend/services/central_settings.py | 14 ++++++++ frontend/src/App.tsx | 18 ++++++---- frontend/src/components/UpdateBanner.tsx | 44 ++++++++++++++++++++++++ frontend/src/hooks/useVersionCheck.ts | 43 +++++++++++++++++++++++ frontend/src/pages/Settings.tsx | 28 ++++++--------- 7 files changed, 141 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/UpdateBanner.tsx create mode 100644 frontend/src/hooks/useVersionCheck.ts diff --git a/backend/api/config.py b/backend/api/config.py index df9bd09..151c8d6 100644 --- a/backend/api/config.py +++ b/backend/api/config.py @@ -14,6 +14,7 @@ import logging from database import get_db from auth import get_current_user, get_all_api_keys, create_api_key, revoke_api_key, delete_api_key +from services.central_settings import get_anthropic_credentials router = APIRouter() logger = logging.getLogger(__name__) @@ -1228,7 +1229,6 @@ class AIInsightsSettingsResponse(BaseModel): class AIInsightsSettingsUpdate(BaseModel): enabled: Optional[bool] = None - api_key: Optional[str] = None model: Optional[str] = None schedule_time: Optional[str] = None daily_token_budget: Optional[int] = None @@ -1239,23 +1239,21 @@ async def get_ai_insights_settings( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): - """Get AI Insights configuration.""" + """Get AI Insights configuration. The Anthropic API key itself is managed + centrally in Portal → Settings → Integrations, not stored here.""" result = await db.execute( text(""" - SELECT config_key, config_value, COALESCE(is_encrypted, false) as is_encrypted + SELECT config_key, config_value FROM system_config WHERE config_key LIKE 'ai_insights_%' """) ) - config = {} - for row in result.fetchall(): - config[row.config_key] = row.config_value - if row.config_key == 'ai_insights_api_key': - config['_api_key_set'] = bool(row.config_value) + config = {row.config_key: row.config_value for row in result.fetchall()} + anthropic_creds = await get_anthropic_credentials() return AIInsightsSettingsResponse( enabled=config.get('ai_insights_enabled', 'false').lower() in ('true', '1', 'yes'), - api_key_set=config.get('_api_key_set', False), + api_key_set=bool(anthropic_creds and anthropic_creds.get('api_key')), model=config.get('ai_insights_model', 'claude-haiku-4-5-20251001'), schedule_time=config.get('ai_insights_schedule_time', '07:15'), daily_token_budget=int(config.get('ai_insights_daily_token_budget', '12000')), @@ -1268,12 +1266,11 @@ async def update_ai_insights_settings( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): - """Update AI Insights configuration.""" + """Update AI Insights configuration. Does not accept an API key — + manage that centrally in Portal → Settings → Integrations.""" updates = {} if settings.enabled is not None: updates['ai_insights_enabled'] = ('true' if settings.enabled else 'false', False) - if settings.api_key is not None and settings.api_key.strip(): - updates['ai_insights_api_key'] = (simple_encrypt(settings.api_key.strip()), True) if settings.model is not None: updates['ai_insights_model'] = (settings.model, False) if settings.schedule_time is not None: @@ -1301,10 +1298,11 @@ async def test_ai_insights_connection( db: AsyncSession = Depends(get_db), current_user: dict = Depends(get_current_user) ): - """Test Anthropic API connection with stored API key.""" - api_key = await _get_config_value(db, "ai_insights_api_key") + """Test Anthropic API connection using the centrally configured API key.""" + anthropic_creds = await get_anthropic_credentials() + api_key = anthropic_creds.get('api_key') if anthropic_creds else None if not api_key: - raise HTTPException(status_code=400, detail="No API key configured") + raise HTTPException(status_code=400, detail="Anthropic API key not configured — add it in Portal → Settings → Integrations") model = await _get_config_value(db, "ai_insights_model") or "claude-haiku-4-5-20251001" diff --git a/backend/jobs/ai_insights.py b/backend/jobs/ai_insights.py index 7f1663c..5111b82 100644 --- a/backend/jobs/ai_insights.py +++ b/backend/jobs/ai_insights.py @@ -17,6 +17,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from database import AsyncSessionLocal +from services.central_settings import get_anthropic_credentials logger = logging.getLogger(__name__) @@ -533,10 +534,11 @@ async def generate_insight(db: AsyncSession, triggered_by: str = "scheduler") -> if config.get('ai_insights_enabled', 'false').lower() not in ('true', '1', 'yes'): return {"success": False, "error": "AI insights disabled"} - # Check API key - api_key = config.get('ai_insights_api_key') + # Check API key — managed centrally in Portal → Settings → Integrations, not app-local + anthropic_creds = await get_anthropic_credentials() + api_key = anthropic_creds.get('api_key') if anthropic_creds else None if not api_key: - return {"success": False, "error": "No API key configured"} + return {"success": False, "error": "Anthropic API key not configured — add it in Portal → Settings → Integrations"} model = config.get('ai_insights_model', DEFAULT_MODEL) budget = int(config.get('ai_insights_daily_token_budget', str(DEFAULT_DAILY_TOKEN_BUDGET))) diff --git a/backend/services/central_settings.py b/backend/services/central_settings.py index e7326ff..09c9044 100644 --- a/backend/services/central_settings.py +++ b/backend/services/central_settings.py @@ -117,3 +117,17 @@ async def get_resos_credentials() -> Optional[dict]: def get_resos_credentials_sync() -> Optional[dict]: """Blocking variant of get_resos_credentials for sync job contexts.""" return _extract_resos(get_integration_sync("resos")) + + +def _extract_anthropic(s: Optional[dict]) -> Optional[dict]: + if not s: + return None + key = s.get("api_key") or "" + if not key: + return None + return {"api_key": key} + + +async def get_anthropic_credentials() -> Optional[dict]: + """Returns {'api_key'} from central settings, or None if not configured.""" + return _extract_anthropic(await get_integration("anthropic")) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1e6adc2..f92dee8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import { Routes, Route, Navigate } from 'react-router-dom' import AuthGate from './components/AuthGate' +import { UpdateBanner } from './components/UpdateBanner' +import { useVersionCheck } from './hooks/useVersionCheck' import Layout from './components/Layout' import Dashboard from './pages/Dashboard' import Forecasts from './pages/Forecasts' @@ -8,10 +10,12 @@ import Accuracy from './pages/Accuracy' import Settings from './pages/Settings' export default function App() { + const updateAvailable = useVersionCheck('/forecasting/health') return ( - - - + <> + + + } /> } /> } /> @@ -23,8 +27,10 @@ export default function App() { } /> } /> } /> - - - + + + + + ) } diff --git a/frontend/src/components/UpdateBanner.tsx b/frontend/src/components/UpdateBanner.tsx new file mode 100644 index 0000000..b492c67 --- /dev/null +++ b/frontend/src/components/UpdateBanner.tsx @@ -0,0 +1,44 @@ +import { RefreshCw } from 'lucide-react' + +export function UpdateBanner({ visible }: { visible: boolean }) { + if (!visible) return null + return ( +
+ A new version is available. + +
+ ) +} diff --git a/frontend/src/hooks/useVersionCheck.ts b/frontend/src/hooks/useVersionCheck.ts new file mode 100644 index 0000000..e4fed28 --- /dev/null +++ b/frontend/src/hooks/useVersionCheck.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react' + +const POLL_MS = 2 * 60 * 1000 + +export function useVersionCheck(healthUrl: string) { + const [updateAvailable, setUpdateAvailable] = useState(false) + + useEffect(() => { + let seenVersion: string | null = null + + async function check() { + try { + const res = await fetch(healthUrl, { cache: 'no-store' }) + if (!res.ok) return + const data = await res.json() + const v: string | undefined = data.version + if (!v) return + if (seenVersion === null) { + seenVersion = v + } else if (v !== seenVersion) { + setUpdateAvailable(true) + } + } catch { + // network error — skip silently + } + } + + check() + const interval = setInterval(check, POLL_MS) + + function onVisible() { + if (document.visibilityState === 'visible') check() + } + document.addEventListener('visibilitychange', onVisible) + + return () => { + clearInterval(interval) + document.removeEventListener('visibilitychange', onVisible) + } + }, [healthUrl]) + + return updateAvailable +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index ffc1cfb..f64a6a1 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -6049,7 +6049,6 @@ interface AIInsightsSettings { const AIInsightsPage: React.FC = () => { const queryClient = useQueryClient() - const [apiKey, setApiKey] = useState('') const [model, setModel] = useState('claude-haiku-4-5-20251001') const [scheduleTime, setScheduleTime] = useState('07:15') const [enabled, setEnabled] = useState(false) @@ -6096,9 +6095,6 @@ const AIInsightsPage: React.FC = () => { schedule_time: scheduleTime, daily_token_budget: tokenBudget, } - if (apiKey.trim()) { - body.api_key = apiKey.trim() - } const response = await fetch('/forecasting/api/config/settings/ai-insights', { method: 'POST', headers: { @@ -6108,7 +6104,6 @@ const AIInsightsPage: React.FC = () => { }) if (response.ok) { setSaveStatus('saved') - setApiKey('') queryClient.invalidateQueries({ queryKey: ['ai-insights-settings'] }) setTimeout(() => setSaveStatus('idle'), 3000) } else { @@ -6194,22 +6189,19 @@ const AIInsightsPage: React.FC = () => { - {/* API Key */} + {/* API Key — managed centrally, not per-app */}
- setApiKey(e.target.value)} - placeholder={settings?.api_key_set ? '•••••••• (key saved)' : 'sk-ant-...'} - style={styles.input} - /> - {settings?.api_key_set && ( - - API key is configured - - )} + + {settings?.api_key_set + ? 'Using centrally configured Anthropic key' + : 'Not configured'} — manage it in Portal → Settings → Integrations +