diff --git a/client/src/components/keys/provider-checklist-section.tsx b/client/src/components/keys/provider-checklist-section.tsx new file mode 100644 index 000000000..85d4044ff --- /dev/null +++ b/client/src/components/keys/provider-checklist-section.tsx @@ -0,0 +1,70 @@ +import { useQuery } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { TableSkeleton } from '@/components/ui/skeleton' +import { Check, X } from 'lucide-react' +import { useI18n } from '@/i18n' + +// Shape of GET /api/keys/providers (#543). Declared inline: the backend owns +// the contract (server/src/routes/keys.ts) and this is the only consumer. +interface ProviderChecklistEntry { + platform: string + name: string + keyless: boolean + configured: boolean + keyCount: number + enabledKeyCount: number +} +interface ProvidersChecklist { + providers: ProviderChecklistEntry[] + summary: { total: number; configured: number; unconfigured: number } +} + +// At-a-glance list of every built-in provider and whether a key has been added +// yet, so you can see what's left to set up without scrolling the key manager +// below and enumerating by hand (#543). +export function ProviderChecklistSection() { + const { t } = useI18n() + const { data, isLoading } = useQuery({ + queryKey: ['keys-providers'], + queryFn: () => apiFetch('/api/keys/providers'), + }) + + if (isLoading) return + if (!data || data.providers.length === 0) return null + + const { providers, summary } = data + + return ( +
+
+

{t('keys.checklistTitle')}

+

+ {t('keys.checklistSummary', { configured: summary.configured, total: summary.total })} +

+
+
+ {providers.map(p => ( +
+ {p.configured ? ( + + ) : ( + + )} + + {p.name} + + {p.keyless && ( + + {t('keys.checklistKeyless')} + + )} +
+ ))} +
+
+ ) +} diff --git a/client/src/i18n/locales/en.json b/client/src/i18n/locales/en.json index 6b72b3616..88691ba01 100644 --- a/client/src/i18n/locales/en.json +++ b/client/src/i18n/locales/en.json @@ -195,6 +195,9 @@ "pageTitle": "Keys", "pageDescription": "Provider credentials and the unified API key your apps connect with.", "tabProviders": "Providers", + "checklistTitle": "Provider checklist", + "checklistSummary": "{configured} of {total} providers set up", + "checklistKeyless": "no key needed", "tabQuotaSignals": "Quota signals", "tabApiKey": "Unified API key", "tabAnthropic": "Anthropic", diff --git a/client/src/pages/KeysPage.tsx b/client/src/pages/KeysPage.tsx index fe347dffb..517c21881 100644 --- a/client/src/pages/KeysPage.tsx +++ b/client/src/pages/KeysPage.tsx @@ -13,6 +13,7 @@ import { UnifiedKeySection } from '@/components/keys/unified-key-section' import { ProxySettingsSection } from '@/components/keys/proxy-settings-section' import { AnthropicSection } from '@/components/keys/anthropic-section' import { ProviderList } from '@/components/keys/provider-list' +import { ProviderChecklistSection } from '@/components/keys/provider-checklist-section' import { AddKeyDialog } from '@/components/keys/add-key-dialog' import { ExportKeysDialog } from '@/components/keys/export-keys-dialog' @@ -100,7 +101,12 @@ export default function KeysPage() { )} - {tab === 'providers' && setAddOpen(true)} />} + {tab === 'providers' && ( + <> + + setAddOpen(true)} /> + + )} diff --git a/server/src/__tests__/routes/keys-providers-checklist.test.ts b/server/src/__tests__/routes/keys-providers-checklist.test.ts new file mode 100644 index 000000000..6d3452fff --- /dev/null +++ b/server/src/__tests__/routes/keys-providers-checklist.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import type { Express } from 'express'; +import { createApp } from '../../app.js'; +import { initDb, getDb } from '../../db/index.js'; +import { encrypt } from '../../lib/crypto.js'; +import { mintDashboardToken } from '../helpers/auth.js'; + +let dashToken = ''; + +async function request(app: Express, path: string, withAuth = true) { + const server = app.listen(0); + const addr = server.address() as { port: number }; + const res = await fetch(`http://127.0.0.1:${addr.port}${path}`, { + headers: withAuth ? { Authorization: `Bearer ${dashToken}` } : {}, + }); + const data = await res.json().catch(() => null); + server.close(); + return { status: res.status, body: data }; +} + +function addKey(platform: string) { + const secret = encrypt(`${platform}-test-key`); + getDb().prepare(` + INSERT INTO api_keys (platform, label, encrypted_key, iv, auth_tag, status, enabled) + VALUES (?, 'test', ?, ?, ?, 'healthy', 1) + `).run(platform, secret.encrypted, secret.iv, secret.authTag); +} + +describe('GET /api/keys/providers — provider checklist (#543)', () => { + let app: Express; + + beforeAll(() => { + process.env.ENCRYPTION_KEY = '0'.repeat(64); + initDb(':memory:'); + app = createApp(); + dashToken = mintDashboardToken(); + }); + + it('requires dashboard auth', async () => { + const { status } = await request(app, '/api/keys/providers', false); + expect(status).toBe(401); + }); + + it('lists every registered provider as unconfigured before any key, excluding custom', async () => { + const { status, body } = await request(app, '/api/keys/providers'); + expect(status).toBe(200); + expect(body.providers.length).toBeGreaterThan(0); + expect(body.providers.every((p: { configured: boolean }) => p.configured === false)).toBe(true); + expect(body.providers.some((p: { platform: string }) => p.platform === 'custom')).toBe(false); + // A well-known free provider is present with the expected shape. + const groq = body.providers.find((p: { platform: string }) => p.platform === 'groq'); + expect(groq).toMatchObject({ platform: 'groq', configured: false, keyCount: 0 }); + expect(typeof groq.name).toBe('string'); + expect(typeof groq.keyless).toBe('boolean'); + expect(body.summary).toEqual({ + total: body.providers.length, + configured: 0, + unconfigured: body.providers.length, + }); + }); + + it('marks a provider configured once it has a key and updates the summary', async () => { + addKey('groq'); + const { status, body } = await request(app, '/api/keys/providers'); + expect(status).toBe(200); + const groq = body.providers.find((p: { platform: string }) => p.platform === 'groq'); + expect(groq).toMatchObject({ configured: true, keyCount: 1, enabledKeyCount: 1 }); + expect(body.summary.configured).toBe(1); + expect(body.summary.unconfigured).toBe(body.summary.total - 1); + }); +}); diff --git a/server/src/routes/keys.ts b/server/src/routes/keys.ts index 324e24840..11d1f275d 100644 --- a/server/src/routes/keys.ts +++ b/server/src/routes/keys.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import multer from 'multer'; import path from 'path'; import { getDb } from '../db/index.js'; -import { resolveProvider } from '../providers/index.js'; +import { resolveProvider, getAllProviders } from '../providers/index.js'; import { encrypt, decrypt, maskKey } from '../lib/crypto.js'; import { parseKeysFromFile, stripJsoncComments, stripTrailingCommas } from '../lib/key-parser.js'; import { assessProviderUrl } from '../lib/url-guard.js'; @@ -145,6 +145,50 @@ function noModelsNotice(platform: string): string | undefined { ); } +// Provider checklist (#543): every registered provider with whether the user +// has added at least one key yet, so the dashboard can show what's still +// missing without enumerating the whole list by hand. `custom` is excluded — +// it's a per-key user-defined placeholder, not a fixed free-tier provider to +// "check off". +keysRouter.get('/providers', (_req: Request, res: Response) => { + const db = getDb(); + const countRows = db.prepare(` + SELECT + platform, + COUNT(*) AS total_keys, + SUM(CASE WHEN enabled = 1 THEN 1 ELSE 0 END) AS enabled_keys + FROM api_keys + GROUP BY platform + `).all() as Array<{ platform: string; total_keys: number; enabled_keys: number }>; + const countsByPlatform = new Map(countRows.map(r => [r.platform, r])); + + const providers = getAllProviders() + .filter(p => p.platform !== 'custom') + .map(p => { + const counts = countsByPlatform.get(p.platform); + const keyCount = counts?.total_keys ?? 0; + return { + platform: p.platform, + name: p.name, + keyless: p.keyless, + configured: keyCount > 0, + keyCount, + enabledKeyCount: counts?.enabled_keys ?? 0, + }; + }) + .sort((a, b) => a.name.localeCompare(b.name)); + + const configured = providers.filter(p => p.configured).length; + res.json({ + providers, + summary: { + total: providers.length, + configured, + unconfigured: providers.length - configured, + }, + }); +}); + // List all keys (masked) keysRouter.get('/', (_req: Request, res: Response) => { const db = getDb();