fix(admin-web): align provider surface with backend and ADR-0024

The org-admin provider page was a stale prototype wired to a removed
singular /provider-connection endpoint. It contradicted the pinned
invariants in several ways:

- It documented a process-env fallback for platform-managed credentials,
  but ADR-0024 / Spec.System.Organization pins the resolver fail-closed
  with no process-global key fallback.
- It exposed a BYOK<->PLATFORM_MANAGED mode toggle to org admins, but
  ADR-0021 makes platform-managed connections platform-admin owned; the
  org-side API (requireByokActor) rejects mutating them.
- It modeled one connection per org, while OrganizationProviderConnection
  is keyed by (org, providerId) and the backend exposes a list plus a
  per-providerId BYOK rotation.
- Its HTTP contract (/provider-connection, {baseUrl, hasAuthToken}) did
  not match the real backend (/provider-connections + /:providerId,
  {status, activeVersion, keyId}).
- It dangled a pointer to role/model pages that do not exist in the SPA;
  roles/skills are managed via the CLI.

Rewrite the page to list connections, show status/version/keyId, and
rotate BYOK credentials per providerId with baseUrl + authToken (+ optional
anthropicApiKey) as the backend requires. Platform-managed rows render
read-only. Drop the fallback copy and the dangling pointer. Replace the
singular ProviderConnection API client with providerConnections /
rotateProviderConnection and remove the now-unused PROVIDER_MODES constant.
This commit is contained in:
2026-07-13 23:46:15 +08:00
parent 3ae0cc3e60
commit 8d2e0cb2c6
3 changed files with 132 additions and 100 deletions
+16 -7
View File
@@ -145,12 +145,13 @@ export interface SessionSummary {
updatedAt: string;
}
export interface ProviderConnection {
organizationId: string;
export interface ProviderConnectionRow {
id: string;
providerId: string;
mode: 'BYOK' | 'PLATFORM_MANAGED';
baseUrl: string | null;
hasAuthToken: boolean;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
@@ -248,7 +249,15 @@ export const api = {
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
},
provider: (slug: string) => get(`${orgBase(slug)}/provider-connection`) as Promise<ProviderConnection>,
setProvider: (slug: string, body: { mode: string; providerId?: string; baseUrl?: string | null; authToken?: string | null }) =>
put(`${orgBase(slug)}/provider-connection`, body) as Promise<ProviderConnection>
providerConnections: (slug: string) =>
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
rotateProviderConnection: (
slug: string,
providerId: string,
body: { baseUrl: string; authToken: string; anthropicApiKey?: string },
) =>
put(
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
body,
) as Promise<ProviderConnectionRow>
};
-5
View File
@@ -37,8 +37,3 @@ export const PERMISSION_ROLE_LABELS: Record<PermissionRole, string> = {
EDIT: '编辑',
MANAGE: '管理'
};
export const PROVIDER_MODES = [
{ value: 'PLATFORM_MANAGED', label: '平台托管' },
{ value: 'BYOK', label: '自带密钥' }
] as const;
@@ -1,36 +1,31 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type ProviderConnection } from '$lib/api';
import { fmtDate } from '$lib/format';
import { PROVIDER_MODES } from '$lib/constants';
import { api, type ProviderConnectionRow } from '$lib/api';
import { fmtDate, providerModeLabel } from '$lib/format';
import { Label } from 'bits-ui';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
const modeItems = PROVIDER_MODES.map((m) => ({ value: m.value, label: m.label }));
let conn = $state<ProviderConnection | null>(null);
let connections = $state<ProviderConnectionRow[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let mode = $state<'BYOK' | 'PLATFORM_MANAGED'>('PLATFORM_MANAGED');
let providerId = $state('openrouter');
let providerId = $state('');
let baseUrl = $state('');
let authToken = $state('');
let anthropicApiKey = $state('');
let saving = $state(false);
async function load() {
loading = true;
error = null;
try {
conn = await api.provider(slug);
mode = conn.mode;
providerId = conn.providerId;
baseUrl = conn.baseUrl ?? '';
const res = await api.providerConnections(slug);
connections = res.connections;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@@ -38,23 +33,44 @@
}
}
function startRotate(row: ProviderConnectionRow) {
providerId = row.providerId;
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
function resetForm() {
providerId = '';
baseUrl = '';
authToken = '';
anthropicApiKey = '';
}
async function save() {
saving = true;
const body: { mode: string; providerId: string; baseUrl?: string; authToken?: string } = {
mode,
providerId: providerId.trim()
};
if (mode === 'BYOK') {
if (baseUrl.trim()) body.baseUrl = baseUrl.trim();
if (authToken !== '') body.authToken = authToken;
const id = providerId.trim();
if (id === '') {
toastError('请填写供应方 ID');
return;
}
const url = baseUrl.trim();
const token = authToken.trim();
if (url === '' || token === '') {
toastError('接口地址与访问令牌均为必填');
return;
}
saving = true;
const body: { baseUrl: string; authToken: string; anthropicApiKey?: string } = {
baseUrl: url,
authToken: token
};
const key = anthropicApiKey.trim();
if (key !== '') body.anthropicApiKey = key;
try {
conn = await api.setProvider(slug, body);
mode = conn.mode;
providerId = conn.providerId;
baseUrl = conn.baseUrl ?? '';
authToken = '';
toastSuccess('供应方配置已保存');
await api.rotateProviderConnection(slug, id, body);
toastSuccess('凭据已轮换');
resetForm();
await load();
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
@@ -69,86 +85,98 @@
<PageHeader
title="模型供应方"
description="连接归属且仅归属本组织。可选择自带密钥,或使用平台为该组织单独托管的凭据。"
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if conn}
<div class="saas-card-pad mb-6">
{:else}
<div class="saas-card overflow-hidden mb-6">
<div class="border-b border-surface-200 px-5 py-3">
<h3 class="text-sm font-semibold text-surface-800">连接</h3>
</div>
{#if connections.length === 0}
<div class="saas-empty"><p class="text-sm text-surface-600">尚无供应方连接</p></div>
{:else}
<div class="overflow-x-auto">
<table class="data-table">
<thead>
<tr>
<th>供应方</th>
<th>凭据模式</th>
<th>状态</th>
<th>版本</th>
<th>更新于</th>
<th></th>
</tr>
</thead>
<tbody>
{#each connections as row}
<tr>
<td class="font-mono text-sm">{row.providerId}</td>
<td>{providerModeLabel(row.mode)}</td>
<td>{row.status}</td>
<td class="tabular-nums">{row.activeVersion ?? '—'}</td>
<td class="text-surface-600">{fmtDate(row.updatedAt)}</td>
<td>
{#if row.mode === 'BYOK'}
<button
class="saas-btn-ghost !px-2 !py-1 text-xs"
onclick={() => startRotate(row)}>轮换</button
>
{:else}
<span class="text-xs text-surface-500">平台管理</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">轮换 BYOK 凭据</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档;保存时需重新填写接口地址与访问令牌。平台托管连接不在此处管理。
</p>
<div class="grid gap-5">
<div>
<p class="saas-label">凭据模式</p>
<SelectField
items={modeItems}
value={mode}
onchange={(v) => {
if (v === 'BYOK' || v === 'PLATFORM_MANAGED') mode = v;
}}
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input
id="provider-id"
class="saas-input font-mono text-sm"
bind:value={providerId}
placeholder="openrouter"
/>
</div>
<div>
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
<input id="provider-id" class="saas-input font-mono text-sm" bind:value={providerId} />
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
/>
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
</div>
<div>
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
</div>
{#if mode === 'BYOK'}
<div>
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
<input
id="base-url"
class="saas-input"
placeholder="https://openrouter.ai/api"
bind:value={baseUrl}
/>
</div>
<div>
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
<input
id="auth-token"
class="saas-input"
type="password"
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : '填写访问令牌'}
bind:value={authToken}
/>
{#if conn.hasAuthToken}
<p class="saas-help">已存储密钥;输入新值替换,留空不变。</p>
{/if}
</div>
{/if}
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<span class="text-xs text-surface-600"
>{conn.updatedAt ? '更新于 ' + fmtDate(conn.updatedAt) : '尚未配置'}</span
>
<div class="flex-1"></div>
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
<button class="saas-btn-primary" onclick={save} disabled={saving}>
{saving ? '保存中…' : '保存'}
</button>
</div>
</div>
<div class="saas-card-pad border-dashed">
<h3 class="saas-section-title mb-2">运行时解析</h3>
<ul class="space-y-2 text-sm text-surface-700">
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>自带密钥:智能体会使用本连接的接口地址与访问令牌。</span>
</li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>
平台托管或未配置时,回退到进程环境变量中的接口地址与访问令牌。真正的按组织托管密钥方案仍在规划中。
</span>
</li>
<li class="flex gap-2">
<span class="text-primary-500"></span>
<span>模型与角色在对应页面管理;未配置时回退环境默认值。</span>
</li>
</ul>
</div>
{/if}