forked from EduCraft/curriculum-project-hub
7f09fb1f13
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
180 lines
5.9 KiB
Svelte
180 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { api, type FeishuApplicationConnection } from '$lib/api';
|
|
import { session } from '$lib/session';
|
|
import { resolveOrg } from '$lib/org';
|
|
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 org = $derived(resolveOrg($session.me, page.url.search));
|
|
const slug = $derived(org?.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}
|