feat(admin-web): add Feishu Application Connection admin page

ADR-0021 pins the organization<->Feishu application binding to 1:1 and
the backend already exposes
  GET    /api/org/:orgSlug/feishu-application-connection
  PUT    /api/org/:orgSlug/feishu-application-connection  (rotate/create)
  DELETE /api/org/:orgSlug/feishu-application-connection  (disable)
backed by FeishuApplicationConnectionService with versioned envelopes
(ADR-0024). The org-admin SPA had no surface for it, so the only
connection type the spec requires was unmanageable from the admin UI.

Add a Feishu page that reads the current connection (status, redacted
app fingerprint, active version, updatedAt), rotates credentials with
appId/appSecret/botOpenId (+ optional verificationToken/encryptKey) as
the backend requires, and disables with confirmation. Add the matching
api client (feishuApplication / rotateFeishuApplication /
disableFeishuApplication), a feishu nav icon and a nav entry.
This commit is contained in:
2026-07-13 23:48:58 +08:00
parent 8d2e0cb2c6
commit 18acc823c3
4 changed files with 223 additions and 4 deletions
+28 -1
View File
@@ -156,6 +156,16 @@ export interface ProviderConnectionRow {
updatedAt: string;
}
export interface FeishuApplicationConnection {
id: string;
appFingerprint: string;
status: 'DRAFT' | 'ACTIVE' | 'DISABLED';
activeVersion: number | null;
keyId: string | null;
createdAt: string;
updatedAt: string;
}
export interface UsageTotals {
runCount: number;
runsWithCost: number;
@@ -259,5 +269,22 @@ export const api = {
put(
`${orgBase(slug)}/provider-connections/${encodeURIComponent(providerId)}`,
body,
) as Promise<ProviderConnectionRow>
) as Promise<ProviderConnectionRow>,
feishuApplication: (slug: string) =>
get(`${orgBase(slug)}/feishu-application-connection`) as Promise<{
connection: FeishuApplicationConnection | null;
}>,
rotateFeishuApplication: (
slug: string,
body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
},
) => put(`${orgBase(slug)}/feishu-application-connection`, body) as Promise<FeishuApplicationConnection>,
disableFeishuApplication: (slug: string) =>
del(`${orgBase(slug)}/feishu-application-connection`) as Promise<FeishuApplicationConnection>
};
@@ -10,6 +10,7 @@
| 'teams'
| 'projects'
| 'provider'
| 'feishu'
| 'menu'
| 'logout'
| 'org'
@@ -54,6 +55,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M13.19 8.688a4.5 4.5 0 016.364 6.364l-3.182 3.182a4.5 4.5 0 01-6.364-6.364" />
<path stroke-linecap="round" stroke-linejoin="round" d="M10.81 15.312a4.5 4.5 0 01-6.364-6.364l3.182-3.182a4.5 4.5 0 016.364 6.364" />
</svg>
{:else if name === 'feishu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4.5 8.25h15a8.25 8.25 0 01-8.25 8.25A8.25 8.25 0 014.5 8.25z"
/>
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 11.25h.01M12 11.25h.01M15.75 11.25h.01" />
</svg>
{:else if name === 'menu'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
+2 -1
View File
@@ -23,7 +23,8 @@
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const }
{ key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const }
];
function isAdmin(org: OrgMembership): boolean {
@@ -0,0 +1,181 @@
<script lang="ts">
import { page } from '$app/state';
import { api, type FeishuApplicationConnection } from '$lib/api';
import { fmtDate } 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 { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? '');
let connection = $state<FeishuApplicationConnection | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let appId = $state('');
let appSecret = $state('');
let botOpenId = $state('');
let verificationToken = $state('');
let encryptKey = $state('');
let saving = $state(false);
let disabling = $state(false);
async function load() {
loading = true;
error = null;
try {
const res = await api.feishuApplication(slug);
connection = res.connection;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
function resetForm() {
appId = '';
appSecret = '';
botOpenId = '';
verificationToken = '';
encryptKey = '';
}
async function save() {
const id = appId.trim();
const secret = appSecret.trim();
const bot = botOpenId.trim();
if (id === '' || secret === '' || bot === '') {
toastError('App ID、App Secret、Bot Open ID 均为必填');
return;
}
saving = true;
const body: {
appId: string;
appSecret: string;
botOpenId: string;
verificationToken?: string;
encryptKey?: string;
} = { appId: id, appSecret: secret, botOpenId: bot };
const vt = verificationToken.trim();
if (vt !== '') body.verificationToken = vt;
const ek = encryptKey.trim();
if (ek !== '') body.encryptKey = ek;
try {
connection = await api.rotateFeishuApplication(slug, body);
resetForm();
toastSuccess('飞书应用凭据已保存');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
saving = false;
}
}
async function disable() {
if (!connection) return;
if (!confirm('停用后该组织将无法收发飞书消息,确定停用?')) return;
disabling = true;
try {
connection = await api.disableFeishuApplication(slug);
toastSuccess('已停用飞书应用连接');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
} finally {
disabling = false;
}
}
$effect(() => {
if (slug) load();
});
</script>
<PageHeader
title="飞书应用"
description="本组织绑定的飞书应用凭据(ADR-0021:组织与应用 1:1)。凭据按组织隔离、版本化信封存储,缺失或校验失败即 fail-closed。"
/>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else}
{#if connection}
<div class="saas-card-pad mb-6">
<p class="saas-section-title mb-3">当前连接</p>
<dl class="space-y-2.5 text-sm">
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">状态</dt>
<dd><span class="saas-badge-success">{connection.status}</span></dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">App 指纹</dt>
<dd class="font-mono text-xs text-surface-800">{connection.appFingerprint}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">版本</dt>
<dd class="tabular-nums">{connection.activeVersion ?? '—'}</dd>
</div>
<div class="flex items-center justify-between gap-3">
<dt class="text-surface-700">更新于</dt>
<dd class="text-surface-600">{fmtDate(connection.updatedAt)}</dd>
</div>
</dl>
{#if connection.status !== 'DISABLED'}
<div class="mt-5 flex items-center justify-end gap-3 border-t border-surface-100 pt-4">
<button class="saas-btn-ghost" onclick={disable} disabled={disabling}>
{disabling ? '停用中…' : '停用连接'}
</button>
</div>
{/if}
</div>
{:else}
<div class="saas-card-pad mb-6">
<p class="text-sm text-surface-700">本组织尚未绑定飞书应用。填写下方凭据以创建连接。</p>
</div>
{/if}
<div class="saas-card-pad">
<h3 class="saas-section-title mb-1">{connection ? '轮换凭据' : '创建连接'}</h3>
<p class="saas-muted mb-4">
密钥仅写入新版本,旧版本归档。{#if connection}App ID 不可变更,须与现有应用一致。{/if}
</p>
<div class="grid gap-5">
<div>
<Label.Root class="saas-label" for="app-id">App ID</Label.Root>
<input id="app-id" class="saas-input font-mono text-sm" bind:value={appId} />
</div>
<div>
<Label.Root class="saas-label" for="app-secret">App Secret</Label.Root>
<input id="app-secret" class="saas-input" type="password" bind:value={appSecret} />
</div>
<div>
<Label.Root class="saas-label" for="bot-open-id">Bot Open ID</Label.Root>
<input id="bot-open-id" class="saas-input font-mono text-sm" bind:value={botOpenId} />
</div>
<div>
<Label.Root class="saas-label" for="verification-token">Verification Token(可选)</Label.Root>
<input
id="verification-token"
class="saas-input"
type="password"
bind:value={verificationToken}
/>
</div>
<div>
<Label.Root class="saas-label" for="encrypt-key">Encrypt Key(可选)</Label.Root>
<input id="encrypt-key" class="saas-input" type="password" bind:value={encryptKey} />
</div>
</div>
<div class="mt-6 flex items-center gap-3 border-t border-surface-100 pt-4">
<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>
{/if}