Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,12 @@ Write each change in both `### English` and `### 中文` under `## Unreleased`.
### English

- Replace native Logs and Models filter dropdowns with compact HeroUI Select controls that match the 32px toolbar
- Add named client API keys with optional provider allowlists, and show the console administrator key on System

### 中文

- Logs 和模型页的筛选下拉改用紧凑 HeroUI Select,高度对齐 32px 工具栏
- 新增可限制供应商的客户端 API 密钥,并在系统页显示控制台管理员密钥

## 0.2.20 - 2026-08-29

Expand Down
10 changes: 6 additions & 4 deletions docs/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,13 @@ They are not the same password.

| Gate | Secret | Where | Unlocks |
|------|--------|-------|---------|
| Console | SQLite API key | `/login` | Overview, accounts, models, API test |
| Console | SQLite `proxy_api_key` | `/login` | Overview, accounts, models, keys, logs, API test, updates |
| Client | SQLite `api_keys` | `/v1` only | Chat and model list, optionally limited to selected providers |
| Qoder | device-flow / PAT | `/accounts` per worker | Upstream chat for that HOME |

`/health` stays open. `/api/*`, `/v1`, worker `/admin/*` and chat require the API key stored in SQLite.
`/health` stays open. Console `/api/*` requires the administrator key. `/v1` accepts the administrator key or a named client key.

The console API key is stored in SQLite `app_secrets` under `proxy_api_key`. A blank database generates a random key on first startup; the key has no environment-variable bootstrap path.
The console API key is stored in SQLite `app_secrets` under `proxy_api_key`. A blank database generates a random key on first startup; the key has no environment-variable bootstrap path. Named client keys live in `api_keys` and only unlock `/v1`. Empty `providers` means every family; a non-empty list is applied in auth, then passed to `PickRoute` as `AllowedProviders`. Do not put key lookup inside the scheduler.

## Account routing

Expand Down Expand Up @@ -459,7 +460,8 @@ Keep the menu short. Login is a gate, not a nav item.
| `/providers` | Models | Catalog + per-model context-window defaults; filter by provider and paginate |
| `/access` | Access | Base URL + quick chat; account and model dropdowns follow the selected account catalog |
| `/logs` | Logs | Request history + runtime process output |
| `/system` | System | Next-version update + SQLite protection |
| `/keys` | API keys | Named client keys with optional provider allowlists |
| `/system` | System | Next-version update, SQLite protection, console administrator key |
| `/auth` | redirect | Legacy → `/accounts` |

Public model IDs are lowercase request identifiers. Qoder CLI names remain display labels, while internal Qoder keys are shown only for routing diagnostics.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { AccountsPage } from '@/pages/AccountsPage'
import { LoginPage } from '@/pages/LoginPage'
import { SystemPage } from '@/pages/SystemPage'
import { LogsPage } from '@/pages/LogsPage'
import { KeysPage } from '@/pages/KeysPage'

export default function App() {
return (
Expand All @@ -30,6 +31,7 @@ export default function App() {
<Route path="/access" element={<AccessPage />} />
<Route path="/accounts" element={<AccountsPage />} />
<Route path="/logs" element={<LogsPage />} />
<Route path="/keys" element={<KeysPage />} />
<Route path="/system" element={<SystemPage />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
Expand Down
54 changes: 54 additions & 0 deletions frontend/src/api/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { api } from './client'

export type APIKeyRecord = {
id: string
name: string
prefix: string
providers: string[]
enabled: boolean
last_used_at?: string
created_at: string
updated_at: string
secret?: string
secret_once?: boolean
}

export type ConsoleKeyView = {
prefix: string
hint?: string
rotated?: boolean
secret?: string
}

export function fetchAPIKeys() {
return api<{ object: string; data: APIKeyRecord[] }>('/api/keys')
}

export function createAPIKey(input: { name: string; providers: string[]; enabled?: boolean }) {
return api<APIKeyRecord>('/api/keys', {
method: 'POST',
body: JSON.stringify(input),
})
}

export function updateAPIKey(id: string, input: { name?: string; providers?: string[]; enabled?: boolean }) {
return api<APIKeyRecord>(`/api/keys/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: JSON.stringify(input),
})
}

export function deleteAPIKey(id: string) {
return api(`/api/keys/${encodeURIComponent(id)}`, { method: 'DELETE' })
}

export function fetchConsoleKey() {
return api<ConsoleKeyView>('/api/system/console-key')
}

export function rotateConsoleKey() {
return api<ConsoleKeyView>('/api/system/console-key', {
method: 'POST',
body: JSON.stringify({ rotate: true }),
})
}
1 change: 1 addition & 0 deletions frontend/src/components/layout/AppLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const routeKey: Record<string, string> = {
'/access': 'access',
'/accounts': 'accounts',
'/logs': 'logs',
'/keys': 'keys',
'/system': 'system',
}

Expand Down
3 changes: 2 additions & 1 deletion frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import { Drawer, Button, Chip, Tooltip } from '@heroui/react'
import { Cube, Gauge, GearSix, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react'
import { Cube, Gauge, GearSix, Key, Lightning, Scroll, SidebarSimple, UsersThree, X } from '@phosphor-icons/react'
import { BrandMark } from '@/components/BrandMark'
import { gsap } from 'gsap'
import { pressScale } from '@/hooks/useGsapReveal'
Expand All @@ -14,6 +14,7 @@ const nav = [
{ to: '/providers', key: 'navProviders', icon: Cube, section: 'develop' },
{ to: '/access', key: 'navAccess', icon: Lightning, section: 'develop' },
{ to: '/logs', key: 'navLogs', icon: Scroll, section: 'system' },
{ to: '/keys', key: 'navKeys', icon: Key, section: 'system' },
{ to: '/system', key: 'navSystem', icon: GearSix, section: 'system' },
] as const

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/ui/PageSkeletons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,15 @@ export function ProvidersPageSkeleton() {
)
}

export function KeysPageSkeleton() {
return (
<div className="space-y-6" aria-label="Loading API keys">
<section className="flex items-end justify-between gap-4 border-b border-[var(--app-line)] pb-4"><div className="space-y-3"><SkeletonBlock className="h-8 w-40" /><SkeletonBlock className="h-4 w-80 max-w-[70vw]" /></div><SkeletonBlock className="h-8 w-28" /></section>
<section className="grid gap-2.5 lg:grid-cols-2 xl:grid-cols-3">{Array.from({ length: 3 }, (_, index) => <div key={index} className="app-panel-flat overflow-hidden rounded-lg p-3"><SkeletonBlock className="h-4 w-28" /><SkeletonBlock className="mt-2 h-3 w-40" /><SkeletonBlock className="mt-4 h-6 w-24" /><SkeletonBlock className="mt-6 h-8 w-full" /></div>)}</section>
</div>
)
}

export function AccessPageSkeleton() {
return (
<div className="space-y-6" aria-label="Loading access settings">
Expand Down
72 changes: 68 additions & 4 deletions frontend/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const messages: Record<Lang, Dict> = {
navAccess: 'Access',
navAccounts: 'Accounts',
navLogs: 'Logs',
navKeys: 'API keys',
navSystem: 'System',
sidebarFoot: 'CLI login-state gateway. More providers later.',
systemUpdateTitle: 'Managed update',
Expand Down Expand Up @@ -86,7 +87,7 @@ export const messages: Record<Lang, Dict> = {
loggingInConsole: 'Checking…',
loginHint: 'Self-hosted only. Account login happens after this, on Accounts.',
loginUnlocks: 'Unlocks',
loginUnlocksValue: 'Overview, accounts, models, logs, API test',
loginUnlocksValue: 'Overview, accounts, models, keys, logs, API test',
loginNot: 'Not this',
loginNotValue: 'Qoder Global account',
loginNext: 'Next',
Expand All @@ -103,7 +104,8 @@ export const messages: Record<Lang, Dict> = {
access: { title: 'Access', desc: 'Copy Base URL and run a quick Chat Completions check.' },
accounts: { title: 'Accounts', desc: 'One login per account. Browser sign-in first; PAT only when needed.' },
logs: { title: 'Logs', desc: 'Recent proxy requests and process output.' },
system: { title: 'System', desc: 'Check and apply the next stable release with SQLite protection.' },
keys: { title: 'API keys', desc: 'Create client keys and limit them to selected providers.' },
system: { title: 'System', desc: 'Managed updates, SQLite protection, and the console API key.' },
login: { title: 'Enter the console', desc: 'Same API key as /v1.' },
},
logsLead: 'Request history and runtime output from this host.',
Expand Down Expand Up @@ -448,6 +450,36 @@ export const messages: Record<Lang, Dict> = {
disabled: 'Disabled',
delete: 'Delete',
deleteAccountConfirm: 'Delete account {name}? This removes its local runtime and stored credential.',
edit: 'Edit',
enabled: 'Enabled',
keysLead: 'Client keys call /v1. Leave providers empty to allow every family. The console key stays on System.',
keysCreate: 'Create key',
keysEmpty: 'No client keys yet',
keysEmptyHint: 'Create a named key for OpenAI-compatible clients. It cannot sign in to this console.',
keysCreateTitle: 'Create API key',
keysCreateHint: 'The secret is shown once. Store it in the client that will call /v1.',
keysEditTitle: 'Edit API key',
keysEditHint: 'Rename the key or change which providers it may route to.',
keysName: 'Name',
keysNamePh: 'CI bot',
keysNameRequired: 'Name is required.',
keysProviders: 'Providers',
keysProvidersHint: 'Leave all unchecked to allow every provider.',
keysAllProviders: 'All providers',
keysLastUsed: 'Last used {time}',
keysNeverUsed: 'Not used yet',
keysSecretTitle: 'Store this secret now',
keysSecretHint: 'This value is not stored in plaintext. It will not be shown again.',
keysSecretOnce: 'Copy the key before closing this dialog.',
keysDeleteConfirm: 'Delete API key {name}? Clients using it will fail immediately.',
consoleKeyTitle: 'Console API key',
consoleKeyHint: 'This is the administrator key used to sign in here.',
consoleKeyLead: 'It can call every provider and manage accounts, keys, and updates. Client keys live on the API keys page.',
consoleKeyRotate: 'Rotate key',
consoleKeyRotateHint: 'Rotating signs you out of other sessions and restarts Qoder workers so they pick up the new secret.',
consoleKeyRotateNow: 'Rotate now',
consoleKeySecretTitle: 'New console key',
consoleKeySecretHint: 'This browser session is already updated. Copy the key for any other clients that used the old console secret.',
},
zh: {
brandSub: '登录态网关',
Expand Down Expand Up @@ -476,6 +508,7 @@ export const messages: Record<Lang, Dict> = {
navAccess: 'API 接入',
navAccounts: '账号',
navLogs: '日志',
navKeys: 'API 密钥',
navSystem: '系统更新',
sidebarFoot: 'CLI 登录态网关,后续扩展更多提供方。',
systemUpdateTitle: '管理更新',
Expand Down Expand Up @@ -532,7 +565,7 @@ export const messages: Record<Lang, Dict> = {
loggingInConsole: '核对中…',
loginHint: '只解锁本机控制台。账号登录在进入后的「账号」页。',
loginUnlocks: '解锁',
loginUnlocksValue: '概览、账号、模型、日志、接口测试',
loginUnlocksValue: '概览、账号、模型、密钥、日志、接口测试',
loginNot: '不是这个',
loginNotValue: 'Qoder 国际版账号',
loginNext: '下一步',
Expand All @@ -549,7 +582,8 @@ export const messages: Record<Lang, Dict> = {
access: { title: '接入', desc: '复制 Base URL,并快速验证 Chat Completions。' },
accounts: { title: '账号', desc: '一个账号一个登录态。先走浏览器登录;浏览器不方便再用 PAT。' },
logs: { title: '日志', desc: '查看近期代理请求和进程输出。' },
system: { title: '系统更新', desc: '按顺序更新到下一个稳定版本,并保护 SQLite 数据。' },
keys: { title: 'API 密钥', desc: '创建客户端密钥,并限制可用的供应商。' },
system: { title: '系统更新', desc: '管理更新、SQLite 保护,以及控制台管理员密钥。' },
login: { title: '进入控制台', desc: '和 /v1 使用同一把 API Key。' },
},
logsLead: '本机请求历史与运行时输出。',
Expand Down Expand Up @@ -894,6 +928,36 @@ export const messages: Record<Lang, Dict> = {
disabled: '已停用',
delete: '删除',
deleteAccountConfirm: '确定删除账号 {name} 吗?这会移除它的本地运行时和已保存凭据。',
edit: '编辑',
enabled: '已启用',
keysLead: '客户端密钥只用于 /v1。供应商留空表示全部可用。控制台管理员密钥在系统页。',
keysCreate: '创建密钥',
keysEmpty: '还没有客户端密钥',
keysEmptyHint: '为 OpenAI 兼容客户端创建命名密钥。它不能登录这个控制台。',
keysCreateTitle: '创建 API 密钥',
keysCreateHint: '明文密钥只显示一次,请保存在会调用 /v1 的客户端里。',
keysEditTitle: '编辑 API 密钥',
keysEditHint: '可以改名称,或限制它能调度到哪些供应商。',
keysName: '名称',
keysNamePh: 'CI 机器人',
keysNameRequired: '请填写名称。',
keysProviders: '供应商',
keysProvidersHint: '全部不勾选表示允许所有供应商。',
keysAllProviders: '全部供应商',
keysLastUsed: '最近使用 {time}',
keysNeverUsed: '尚未使用',
keysSecretTitle: '请立刻保存这把密钥',
keysSecretHint: '服务端只存哈希,关闭后无法再查看明文。',
keysSecretOnce: '关闭弹窗前请先复制。',
keysDeleteConfirm: '确定删除 API 密钥 {name} 吗?正在使用它的客户端会立刻失败。',
consoleKeyTitle: '控制台 API 密钥',
consoleKeyHint: '这是登录本控制台的管理员密钥。',
consoleKeyLead: '它可以调用全部供应商,并管理账号、密钥和更新。客户端密钥在「API 密钥」页。',
consoleKeyRotate: '轮换密钥',
consoleKeyRotateHint: '轮换后其他会话会失效,并重启 Qoder worker 以使用新密钥。',
consoleKeyRotateNow: '立即轮换',
consoleKeySecretTitle: '新的控制台密钥',
consoleKeySecretHint: '当前浏览器会话已更新。如果还有客户端在用旧的控制台密钥,请把新值复制过去。',
},
}

Expand Down
Loading
Loading