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 +