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 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-24 20:43:03 +00:00
parent 72f607132c
commit 4137813fc7
7 changed files with 141 additions and 42 deletions

View file

@ -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"

View file

@ -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)))

View file

@ -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"))

View file

@ -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 (
<AuthGate>
<Layout>
<Routes>
<>
<AuthGate>
<Layout>
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/forecasts" element={<Forecasts />} />
@ -23,8 +27,10 @@ export default function App() {
<Route path="/settings" element={<Settings />} />
<Route path="/settings/:tab" element={<Settings />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</Layout>
</AuthGate>
</Routes>
</Layout>
</AuthGate>
<UpdateBanner visible={updateAvailable} />
</>
)
}

View file

@ -0,0 +1,44 @@
import { RefreshCw } from 'lucide-react'
export function UpdateBanner({ visible }: { visible: boolean }) {
if (!visible) return null
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 9999,
background: 'var(--sidebar)',
color: 'var(--text-light)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
padding: '10px 16px',
fontSize: '14px',
boxShadow: '0 -2px 8px rgba(0,0,0,0.3)',
}}>
<span>A new version is available.</span>
<button
onClick={() => window.location.reload()}
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
background: 'var(--accent)',
color: 'var(--sidebar)',
border: 'none',
borderRadius: '4px',
padding: '6px 14px',
fontWeight: 600,
cursor: 'pointer',
fontSize: '13px',
}}
>
<RefreshCw size={14} strokeWidth={1.75} />
Reload
</button>
</div>
)
}

View file

@ -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
}

View file

@ -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 = () => {
</label>
</div>
{/* API Key */}
{/* API Key — managed centrally, not per-app */}
<div style={styles.formRow}>
<div style={styles.formField}>
<label style={styles.label}>Anthropic API Key</label>
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={settings?.api_key_set ? '•••••••• (key saved)' : 'sk-ant-...'}
style={styles.input}
/>
{settings?.api_key_set && (
<span style={{ fontSize: typography.xs, color: colors.success, marginTop: spacing.xs, display: 'block' }}>
API key is configured
</span>
)}
<span style={{
fontSize: typography.sm,
color: settings?.api_key_set ? colors.success : colors.error,
display: 'block',
}}>
{settings?.api_key_set
? 'Using centrally configured Anthropic key'
: 'Not configured'} manage it in Portal Settings Integrations
</span>
</div>
</div>