forked from EduCraft/curriculum-project-hub
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:
@@ -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>
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
try {
|
||||
conn = await api.setProvider(slug, body);
|
||||
mode = conn.mode;
|
||||
providerId = conn.providerId;
|
||||
baseUrl = conn.baseUrl ?? '';
|
||||
function startRotate(row: ProviderConnectionRow) {
|
||||
providerId = row.providerId;
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
toastSuccess('供应方配置已保存');
|
||||
anthropicApiKey = '';
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
providerId = '';
|
||||
baseUrl = '';
|
||||
authToken = '';
|
||||
anthropicApiKey = '';
|
||||
}
|
||||
|
||||
async function save() {
|
||||
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 {
|
||||
await api.rotateProviderConnection(slug, id, body);
|
||||
toastSuccess('凭据已轮换');
|
||||
resetForm();
|
||||
await load();
|
||||
} catch (err) {
|
||||
toastError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
@@ -69,33 +85,74 @@
|
||||
|
||||
<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} />
|
||||
</div>
|
||||
|
||||
{#if mode === 'BYOK'}
|
||||
<div>
|
||||
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
||||
<input
|
||||
@@ -107,48 +164,19 @@
|
||||
</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}
|
||||
<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}
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user