Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions client/src/components/keys/provider-checklist-section.tsx
Original file line number Diff line number Diff line change
@@ -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<ProvidersChecklist>({
queryKey: ['keys-providers'],
queryFn: () => apiFetch('/api/keys/providers'),
})

if (isLoading) return <TableSkeleton rows={3} />
if (!data || data.providers.length === 0) return null

const { providers, summary } = data

return (
<div className="mb-8">
<div className="mb-3">
<h3 className="text-sm font-medium">{t('keys.checklistTitle')}</h3>
<p className="text-xs text-muted-foreground">
{t('keys.checklistSummary', { configured: summary.configured, total: summary.total })}
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{providers.map(p => (
<div
key={p.platform}
className="flex items-center gap-2.5 rounded-xl border bg-card px-3 py-2"
>
{p.configured ? (
<Check className="size-4 flex-shrink-0 text-emerald-500" />
) : (
<X className="size-4 flex-shrink-0 text-muted-foreground" />
)}
<span className={`truncate text-sm ${p.configured ? '' : 'text-muted-foreground'}`}>
{p.name}
</span>
{p.keyless && (
<Badge variant="outline" className="ml-auto">
{t('keys.checklistKeyless')}
</Badge>
)}
</div>
))}
</div>
</div>
)
}
3 changes: 3 additions & 0 deletions client/src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 7 additions & 1 deletion client/src/pages/KeysPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -100,7 +101,12 @@ export default function KeysPage() {
<QuotaSignalsSection states={(healthData?.quotaStates ?? []).slice(0, 24)} />
)}

{tab === 'providers' && <ProviderList onAddKey={() => setAddOpen(true)} />}
{tab === 'providers' && (
<>
<ProviderChecklistSection />
<ProviderList onAddKey={() => setAddOpen(true)} />
</>
)}
</div>

<AddKeyDialog open={addOpen} onOpenChange={setAddOpen} />
Expand Down
71 changes: 71 additions & 0 deletions server/src/__tests__/routes/keys-providers-checklist.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
46 changes: 45 additions & 1 deletion server/src/routes/keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down