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;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderConnection {
|
export interface ProviderConnectionRow {
|
||||||
organizationId: string;
|
id: string;
|
||||||
providerId: string;
|
providerId: string;
|
||||||
mode: 'BYOK' | 'PLATFORM_MANAGED';
|
mode: 'BYOK' | 'PLATFORM_MANAGED';
|
||||||
baseUrl: string | null;
|
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
|
||||||
hasAuthToken: boolean;
|
activeVersion: number | null;
|
||||||
|
keyId: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -248,7 +249,15 @@ export const api = {
|
|||||||
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
|
return get(`${orgBase(slug)}/usage${qs ? `?${qs}` : ''}`) as Promise<UsageReport>;
|
||||||
},
|
},
|
||||||
|
|
||||||
provider: (slug: string) => get(`${orgBase(slug)}/provider-connection`) as Promise<ProviderConnection>,
|
providerConnections: (slug: string) =>
|
||||||
setProvider: (slug: string, body: { mode: string; providerId?: string; baseUrl?: string | null; authToken?: string | null }) =>
|
get(`${orgBase(slug)}/provider-connections`) as Promise<{ connections: ProviderConnectionRow[] }>,
|
||||||
put(`${orgBase(slug)}/provider-connection`, body) as Promise<ProviderConnection>
|
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: '编辑',
|
EDIT: '编辑',
|
||||||
MANAGE: '管理'
|
MANAGE: '管理'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PROVIDER_MODES = [
|
|
||||||
{ value: 'PLATFORM_MANAGED', label: '平台托管' },
|
|
||||||
{ value: 'BYOK', label: '自带密钥' }
|
|
||||||
] as const;
|
|
||||||
|
|||||||
@@ -1,36 +1,31 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import { api, type ProviderConnection } from '$lib/api';
|
import { api, type ProviderConnectionRow } from '$lib/api';
|
||||||
import { fmtDate } from '$lib/format';
|
import { fmtDate, providerModeLabel } from '$lib/format';
|
||||||
import { PROVIDER_MODES } from '$lib/constants';
|
|
||||||
import { Label } from 'bits-ui';
|
import { Label } from 'bits-ui';
|
||||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||||
import LoadingState from '$lib/components/LoadingState.svelte';
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
||||||
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
||||||
import SelectField from '$lib/components/SelectField.svelte';
|
|
||||||
import { toastError, toastSuccess } from '$lib/toast';
|
import { toastError, toastSuccess } from '$lib/toast';
|
||||||
|
|
||||||
const slug = $derived(page.params.slug ?? '');
|
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 loading = $state(true);
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
|
|
||||||
let mode = $state<'BYOK' | 'PLATFORM_MANAGED'>('PLATFORM_MANAGED');
|
let providerId = $state('');
|
||||||
let providerId = $state('openrouter');
|
|
||||||
let baseUrl = $state('');
|
let baseUrl = $state('');
|
||||||
let authToken = $state('');
|
let authToken = $state('');
|
||||||
|
let anthropicApiKey = $state('');
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
conn = await api.provider(slug);
|
const res = await api.providerConnections(slug);
|
||||||
mode = conn.mode;
|
connections = res.connections;
|
||||||
providerId = conn.providerId;
|
|
||||||
baseUrl = conn.baseUrl ?? '';
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err instanceof Error ? err.message : String(err);
|
error = err instanceof Error ? err.message : String(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -38,23 +33,44 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
function startRotate(row: ProviderConnectionRow) {
|
||||||
saving = true;
|
providerId = row.providerId;
|
||||||
const body: { mode: string; providerId: string; baseUrl?: string; authToken?: string } = {
|
baseUrl = '';
|
||||||
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 ?? '';
|
|
||||||
authToken = '';
|
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) {
|
} catch (err) {
|
||||||
toastError(err instanceof Error ? err.message : String(err));
|
toastError(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
@@ -69,33 +85,74 @@
|
|||||||
|
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="模型供应方"
|
title="模型供应方"
|
||||||
description="连接归属且仅归属本组织。可选择自带密钥,或使用平台为该组织单独托管的凭据。"
|
description="本组织的模型供应方连接。BYOK 由组织所有者/管理员轮换;平台托管连接由平台管理员配置。凭据按组织隔离,缺失或校验失败即拒绝运行(fail-closed)。"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<ErrorBanner message={error} onretry={load} />
|
<ErrorBanner message={error} onretry={load} />
|
||||||
{:else if conn}
|
{:else}
|
||||||
<div class="saas-card-pad mb-6">
|
<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 class="grid gap-5">
|
||||||
<div>
|
<div>
|
||||||
<p class="saas-label">凭据模式</p>
|
<Label.Root class="saas-label" for="provider-id">供应方 ID</Label.Root>
|
||||||
<SelectField
|
<input
|
||||||
items={modeItems}
|
id="provider-id"
|
||||||
value={mode}
|
class="saas-input font-mono text-sm"
|
||||||
onchange={(v) => {
|
bind:value={providerId}
|
||||||
if (v === 'BYOK' || v === 'PLATFORM_MANAGED') mode = v;
|
placeholder="openrouter"
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
<Label.Root class="saas-label" for="base-url">接口地址</Label.Root>
|
||||||
<input
|
<input
|
||||||
@@ -107,48 +164,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
<Label.Root class="saas-label" for="auth-token">访问令牌</Label.Root>
|
||||||
<input
|
<input id="auth-token" class="saas-input" type="password" bind:value={authToken} />
|
||||||
id="auth-token"
|
</div>
|
||||||
class="saas-input"
|
<div>
|
||||||
type="password"
|
<Label.Root class="saas-label" for="anthropic-key">Anthropic API Key(可选)</Label.Root>
|
||||||
placeholder={conn.hasAuthToken ? '已设置(留空则不变)' : '填写访问令牌'}
|
<input id="anthropic-key" class="saas-input" type="password" bind:value={anthropicApiKey} />
|
||||||
bind:value={authToken}
|
|
||||||
/>
|
|
||||||
{#if conn.hasAuthToken}
|
|
||||||
<p class="saas-help">已存储密钥;输入新值替换,留空不变。</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
|
<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>
|
<div class="flex-1"></div>
|
||||||
|
<button class="saas-btn-ghost" onclick={resetForm} disabled={saving}>清空</button>
|
||||||
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
<button class="saas-btn-primary" onclick={save} disabled={saving}>
|
||||||
{saving ? '保存中…' : '保存'}
|
{saving ? '保存中…' : '保存'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user