mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-11-01 16:29:29 +00:00
feat: add OAuth outbound transport plugin system
This commit is contained in:
@@ -0,0 +1,671 @@
|
||||
<template>
|
||||
<AppLayout>
|
||||
<div class="space-y-6">
|
||||
<section
|
||||
class="flex flex-col gap-4 border-b border-gray-200 pb-5 dark:border-dark-700 sm:flex-row sm:items-end sm:justify-between"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t("admin.plugins.title") }}
|
||||
</h2>
|
||||
<p class="mt-1 max-w-3xl text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.description") }}
|
||||
</p>
|
||||
<div
|
||||
class="mt-3 flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
|
||||
t("admin.plugins.onlyOpenAI")
|
||||
}}</span>
|
||||
<span class="rounded bg-gray-100 px-2 py-1 dark:bg-dark-700">{{
|
||||
t("admin.plugins.noAccountCoupling")
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-shrink-0 items-center gap-2">
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="hidden"
|
||||
type="file"
|
||||
accept=".s2plugin,application/zip"
|
||||
@change="handleFileSelected"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="uploading"
|
||||
@click="fileInput?.click()"
|
||||
>
|
||||
<Icon name="upload" size="sm" />
|
||||
{{ uploading ? t("common.processing") : t("admin.plugins.upload") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
:disabled="loading"
|
||||
:title="t('common.refresh')"
|
||||
@click="loadPlugins"
|
||||
>
|
||||
<Icon name="refresh" size="sm" />
|
||||
<span class="sr-only">{{ t("common.refresh") }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.uploadHint") }}
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="border border-blue-200 bg-blue-50 px-4 py-3 text-sm text-blue-800 dark:border-blue-900/60 dark:bg-blue-950/30 dark:text-blue-200"
|
||||
>
|
||||
<p>{{ t("admin.plugins.runtimeNotice") }}</p>
|
||||
<p class="mt-1">{{ t("admin.plugins.menuNotice") }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex min-h-48 items-center justify-center text-sm text-gray-500"
|
||||
>
|
||||
{{ t("common.loading") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="plugins.length === 0"
|
||||
class="flex min-h-56 flex-col items-center justify-center border border-dashed border-gray-300 px-6 text-center dark:border-dark-600"
|
||||
>
|
||||
<Icon name="cube" size="xl" class="text-gray-400" />
|
||||
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t("admin.plugins.empty") }}
|
||||
</p>
|
||||
<p class="mt-1 max-w-lg text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t("admin.plugins.emptyHint") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<article
|
||||
v-for="plugin in plugins"
|
||||
:key="plugin.id"
|
||||
class="card overflow-hidden border border-gray-200 dark:border-dark-700"
|
||||
>
|
||||
<div
|
||||
class="flex flex-wrap items-start justify-between gap-3 border-b border-gray-100 p-5 dark:border-dark-700"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3
|
||||
class="truncate text-base font-semibold text-gray-900 dark:text-white"
|
||||
>
|
||||
{{ plugin.name }}
|
||||
</h3>
|
||||
<span class="font-mono text-xs text-gray-500"
|
||||
>v{{ plugin.version }}</span
|
||||
>
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="stateClass(plugin.state)"
|
||||
>
|
||||
{{ t(`admin.plugins.${plugin.state}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
{{ plugin.plugin_key
|
||||
}}<span v-if="plugin.author"> · {{ plugin.author }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="plugin.description"
|
||||
class="mt-2 text-sm text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{{ plugin.description }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
@click="openConfiguration(plugin)"
|
||||
>
|
||||
<Icon name="cog" size="sm" />
|
||||
{{ t("admin.plugins.configure") }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-x-6 gap-y-4 p-5 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-gray-500">
|
||||
{{ t("admin.plugins.compatibility") }}
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<span
|
||||
class="rounded px-2 py-0.5 text-xs font-medium"
|
||||
:class="compatibilityClass(plugin.compatibility.status)"
|
||||
>
|
||||
{{ t(`admin.plugins.${plugin.compatibility.status}`) }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{
|
||||
plugin.compatibility.message
|
||||
}}</span>
|
||||
</div>
|
||||
<dl
|
||||
class="mt-3 grid grid-cols-[auto,1fr] gap-x-3 gap-y-1 text-xs"
|
||||
>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.currentVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.current_sub2api_version }}
|
||||
</dd>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.requiredVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.required_sub2api_version }}
|
||||
</dd>
|
||||
<dt class="text-gray-500">
|
||||
{{ t("admin.plugins.recommendedVersion") }}
|
||||
</dt>
|
||||
<dd class="font-mono text-gray-800 dark:text-gray-200">
|
||||
{{ plugin.compatibility.recommended_sub2api_version || "-" }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase text-gray-500">
|
||||
{{ t("admin.plugins.runtime") }}
|
||||
</p>
|
||||
<div class="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span
|
||||
class="rounded px-2 py-0.5"
|
||||
:class="
|
||||
plugin.runtime_healthy
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300'
|
||||
"
|
||||
>
|
||||
{{
|
||||
plugin.runtime_healthy
|
||||
? t("admin.plugins.healthy")
|
||||
: t("admin.plugins.unhealthy")
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-gray-600 dark:bg-dark-700 dark:text-gray-300"
|
||||
>
|
||||
{{ t("admin.plugins.signature") }}:
|
||||
{{ t(`admin.plugins.${plugin.signature_status}`) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="plugin.last_error"
|
||||
class="mt-3 break-words text-xs text-red-600 dark:text-red-400"
|
||||
>
|
||||
{{ plugin.last_error }}
|
||||
</p>
|
||||
<p
|
||||
v-else-if="plugin.runtime_message"
|
||||
class="mt-3 break-words text-xs text-gray-500"
|
||||
>
|
||||
{{ plugin.runtime_message }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label
|
||||
class="flex items-center justify-between gap-4 text-xs font-medium text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
<span>{{ t("admin.plugins.rollout") }}</span>
|
||||
<span class="w-11 text-right font-mono"
|
||||
>{{
|
||||
rolloutValues[plugin.id] ?? currentRollout(plugin)
|
||||
}}%</span
|
||||
>
|
||||
</label>
|
||||
<input
|
||||
:value="rolloutValues[plugin.id] ?? currentRollout(plugin)"
|
||||
type="range"
|
||||
min="1"
|
||||
max="100"
|
||||
step="1"
|
||||
class="mt-2 w-full accent-primary-600"
|
||||
:disabled="hasEnabledBinding(plugin)"
|
||||
@input="setRollout(plugin.id, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap justify-end gap-2 border-t border-gray-100 px-5 py-4 dark:border-dark-700"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busyID === plugin.id"
|
||||
@click="testPlugin(plugin)"
|
||||
>
|
||||
<Icon name="beaker" size="sm" />
|
||||
{{ t("admin.plugins.test") }}
|
||||
</button>
|
||||
<button
|
||||
v-if="hasEnabledBinding(plugin)"
|
||||
type="button"
|
||||
class="btn btn-secondary btn-sm"
|
||||
:disabled="busyID === plugin.id"
|
||||
@click="disablePlugin(plugin)"
|
||||
>
|
||||
<Icon name="ban" size="sm" />
|
||||
{{ t("admin.plugins.disable") }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="
|
||||
busyID === plugin.id ||
|
||||
plugin.state === 'starting' ||
|
||||
!plugin.compatibility.compatible
|
||||
"
|
||||
@click="enablePlugin(plugin)"
|
||||
>
|
||||
<Icon name="play" size="sm" />
|
||||
{{ t("admin.plugins.enable") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-danger btn-sm"
|
||||
:disabled="busyID === plugin.id || hasEnabledBinding(plugin)"
|
||||
@click="uninstallPlugin(plugin)"
|
||||
>
|
||||
<Icon name="trash" size="sm" />
|
||||
{{ t("admin.plugins.uninstall") }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseDialog
|
||||
:show="configPlugin !== null"
|
||||
:title="
|
||||
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
|
||||
"
|
||||
width="full"
|
||||
@close="closeConfiguration"
|
||||
>
|
||||
<div
|
||||
class="relative min-h-[520px] overflow-hidden bg-gray-50 dark:bg-dark-900"
|
||||
:style="{ height: `${iframeHeight}px` }"
|
||||
>
|
||||
<div
|
||||
v-if="uiLoading"
|
||||
class="absolute inset-0 z-10 flex items-center justify-center text-sm text-gray-500"
|
||||
>
|
||||
{{ t("admin.plugins.loadingUI") }}
|
||||
</div>
|
||||
<div
|
||||
v-if="uiError"
|
||||
class="absolute inset-0 z-20 flex flex-col items-center justify-center p-8 text-center"
|
||||
>
|
||||
<Icon name="exclamationTriangle" size="xl" class="text-amber-500" />
|
||||
<p class="mt-3 font-medium text-gray-800 dark:text-gray-200">
|
||||
{{ t("admin.plugins.uiUnavailable") }}
|
||||
</p>
|
||||
<p class="mt-1 max-w-xl text-sm text-gray-500">{{ uiError }}</p>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="uiSession"
|
||||
ref="pluginFrame"
|
||||
:src="uiSession.url"
|
||||
sandbox="allow-scripts"
|
||||
referrerpolicy="no-referrer"
|
||||
class="h-full w-full border-0 bg-white dark:bg-dark-900"
|
||||
:title="
|
||||
t('admin.plugins.configTitle', { name: configPlugin?.name || '' })
|
||||
"
|
||||
@load="uiLoading = false"
|
||||
/>
|
||||
</div>
|
||||
</BaseDialog>
|
||||
|
||||
<TotpStepUpDialog :controller="pluginStepUp" />
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import {
|
||||
adminAPI,
|
||||
type PluginInstallation,
|
||||
type PluginUISession,
|
||||
} from "@/api/admin";
|
||||
import { useAppStore } from "@/stores";
|
||||
import AppLayout from "@/components/layout/AppLayout.vue";
|
||||
import BaseDialog from "@/components/common/BaseDialog.vue";
|
||||
import Icon from "@/components/icons/Icon.vue";
|
||||
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
|
||||
import {
|
||||
isStepUpBlocked,
|
||||
isStepUpCancelled,
|
||||
stepUpBlockReason,
|
||||
useStepUp,
|
||||
} from "@/composables/useStepUp";
|
||||
|
||||
interface PluginBridgeMessage {
|
||||
source?: string;
|
||||
bridge_token?: string;
|
||||
type?: string;
|
||||
request_id?: string;
|
||||
config?: unknown;
|
||||
height?: unknown;
|
||||
level?: unknown;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
const { t } = useI18n();
|
||||
const appStore = useAppStore();
|
||||
const pluginStepUp = useStepUp();
|
||||
const plugins = ref<PluginInstallation[]>([]);
|
||||
const loading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const busyID = ref<number | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const rolloutValues = ref<Record<number, number>>({});
|
||||
const configPlugin = ref<PluginInstallation | null>(null);
|
||||
const uiSession = ref<PluginUISession | null>(null);
|
||||
const pluginFrame = ref<HTMLIFrameElement | null>(null);
|
||||
const uiLoading = ref(false);
|
||||
const uiError = ref("");
|
||||
const iframeHeight = ref(640);
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (typeof error === "object" && error !== null && "message" in error) {
|
||||
return String(
|
||||
(error as { message?: unknown }).message || t("common.unknownError"),
|
||||
);
|
||||
}
|
||||
return t("common.unknownError");
|
||||
}
|
||||
|
||||
function reportSensitiveActionError(error: unknown): void {
|
||||
if (isStepUpCancelled(error)) return;
|
||||
if (isStepUpBlocked(error)) {
|
||||
appStore.showError(
|
||||
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
|
||||
? t("stepUp.adminApiKeyForbidden")
|
||||
: t("stepUp.notEnabled"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
appStore.showError(errorMessage(error));
|
||||
}
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
plugins.value = await adminAPI.plugins.list();
|
||||
for (const plugin of plugins.value) {
|
||||
rolloutValues.value[plugin.id] = currentRollout(plugin);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
appStore.showError(errorMessage(error));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileSelected(event: Event): Promise<void> {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const file = target.files?.[0];
|
||||
target.value = "";
|
||||
if (!file || !file.name.toLowerCase().endsWith(".s2plugin")) {
|
||||
appStore.showError(t("admin.plugins.fileRequired"));
|
||||
return;
|
||||
}
|
||||
uploading.value = true;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.upload(file));
|
||||
appStore.showSuccess(t("admin.plugins.uploadSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
uploading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function currentRollout(plugin: PluginInstallation): number {
|
||||
return (
|
||||
plugin.bindings.find(
|
||||
(binding) => binding.capability === "openai.oauth.outbound_transport.v1",
|
||||
)?.rollout_percent || 100
|
||||
);
|
||||
}
|
||||
|
||||
function hasEnabledBinding(plugin: PluginInstallation): boolean {
|
||||
return plugin.bindings.some((binding) => binding.enabled);
|
||||
}
|
||||
|
||||
function setRollout(id: number, event: Event): void {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
rolloutValues.value[id] = Math.min(100, Math.max(1, value));
|
||||
}
|
||||
|
||||
async function enablePlugin(plugin: PluginInstallation): Promise<void> {
|
||||
let acceptUntested = false;
|
||||
if (!plugin.compatibility.tested) {
|
||||
acceptUntested = window.confirm(t("admin.plugins.confirmUntested"));
|
||||
if (!acceptUntested) return;
|
||||
}
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.enable(
|
||||
plugin.id,
|
||||
rolloutValues.value[plugin.id] || 100,
|
||||
acceptUntested,
|
||||
),
|
||||
);
|
||||
appStore.showSuccess(t("admin.plugins.enableSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePlugin(plugin: PluginInstallation): Promise<void> {
|
||||
if (!window.confirm(t("admin.plugins.confirmDisable"))) return;
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.disable(plugin.id));
|
||||
appStore.showSuccess(t("admin.plugins.disableSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(plugin: PluginInstallation): Promise<void> {
|
||||
if (!window.confirm(t("admin.plugins.confirmUninstall"))) return;
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
await pluginStepUp.run(() => adminAPI.plugins.remove(plugin.id));
|
||||
appStore.showSuccess(t("admin.plugins.uninstallSuccess"));
|
||||
await loadPlugins();
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testPlugin(plugin: PluginInstallation): Promise<void> {
|
||||
busyID.value = plugin.id;
|
||||
try {
|
||||
const result = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.test(plugin.id),
|
||||
);
|
||||
if (result.success)
|
||||
appStore.showSuccess(result.message || t("admin.plugins.testSuccess"));
|
||||
else appStore.showError(result.message || t("common.error"));
|
||||
} catch (error: unknown) {
|
||||
reportSensitiveActionError(error);
|
||||
} finally {
|
||||
busyID.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function openConfiguration(plugin: PluginInstallation): Promise<void> {
|
||||
configPlugin.value = plugin;
|
||||
uiSession.value = null;
|
||||
uiLoading.value = true;
|
||||
uiError.value = "";
|
||||
iframeHeight.value = 640;
|
||||
try {
|
||||
uiSession.value = await adminAPI.plugins.createUISession(plugin.id);
|
||||
} catch (error: unknown) {
|
||||
uiLoading.value = false;
|
||||
uiError.value = errorMessage(error);
|
||||
}
|
||||
}
|
||||
|
||||
function closeConfiguration(): void {
|
||||
configPlugin.value = null;
|
||||
uiSession.value = null;
|
||||
uiLoading.value = false;
|
||||
uiError.value = "";
|
||||
}
|
||||
|
||||
function postBridgeResult(
|
||||
request: PluginBridgeMessage,
|
||||
payload: Record<string, unknown>,
|
||||
): void {
|
||||
if (!pluginFrame.value?.contentWindow || !uiSession.value) return;
|
||||
pluginFrame.value.contentWindow.postMessage(
|
||||
{
|
||||
source: "sub2api-plugin-host",
|
||||
bridge_token: uiSession.value.bridge_token,
|
||||
type: `${request.type}.result`,
|
||||
request_id: request.request_id,
|
||||
...payload,
|
||||
},
|
||||
"*",
|
||||
);
|
||||
}
|
||||
|
||||
async function handleBridgeMessage(event: MessageEvent): Promise<void> {
|
||||
if (
|
||||
!uiSession.value ||
|
||||
!configPlugin.value ||
|
||||
event.source !== pluginFrame.value?.contentWindow
|
||||
)
|
||||
return;
|
||||
const message = event.data as PluginBridgeMessage;
|
||||
if (
|
||||
!message ||
|
||||
message.source !== "sub2api-plugin-ui" ||
|
||||
message.bridge_token !== uiSession.value.bridge_token
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
switch (message.type) {
|
||||
case "sub2api.plugin.ready":
|
||||
uiLoading.value = false;
|
||||
break;
|
||||
case "config.load": {
|
||||
const config = await adminAPI.plugins.getConfig(configPlugin.value.id);
|
||||
postBridgeResult(message, { ok: true, config });
|
||||
break;
|
||||
}
|
||||
case "config.save": {
|
||||
if (
|
||||
!message.config ||
|
||||
typeof message.config !== "object" ||
|
||||
Array.isArray(message.config)
|
||||
) {
|
||||
throw new Error(t("admin.plugins.bridgeRejected"));
|
||||
}
|
||||
const config = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.saveConfig(
|
||||
configPlugin.value!.id,
|
||||
message.config as Record<string, unknown>,
|
||||
),
|
||||
);
|
||||
postBridgeResult(message, { ok: true, config });
|
||||
appStore.showSuccess(t("common.saved"));
|
||||
break;
|
||||
}
|
||||
case "config.test": {
|
||||
const result = await pluginStepUp.run(() =>
|
||||
adminAPI.plugins.test(configPlugin.value!.id),
|
||||
);
|
||||
postBridgeResult(message, { ok: result.success, result });
|
||||
if (result.success)
|
||||
appStore.showSuccess(
|
||||
result.message || t("admin.plugins.testSuccess"),
|
||||
);
|
||||
else appStore.showError(result.message || t("common.error"));
|
||||
break;
|
||||
}
|
||||
case "ui.resize": {
|
||||
const height = Number(message.height);
|
||||
if (Number.isFinite(height))
|
||||
iframeHeight.value = Math.min(960, Math.max(520, Math.round(height)));
|
||||
break;
|
||||
}
|
||||
case "ui.notify": {
|
||||
const text =
|
||||
typeof message.message === "string"
|
||||
? message.message.slice(0, 500)
|
||||
: "";
|
||||
if (!text) break;
|
||||
if (message.level === "error") appStore.showError(text);
|
||||
else if (message.level === "success") appStore.showSuccess(text);
|
||||
else appStore.showInfo(text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
if (isStepUpBlocked(error)) reportSensitiveActionError(error);
|
||||
postBridgeResult(message, {
|
||||
ok: false,
|
||||
error: isStepUpCancelled(error) ? t("common.cancel") : errorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stateClass(state: PluginInstallation["state"]): string {
|
||||
if (state === "enabled")
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (state === "error" || state === "incompatible")
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
|
||||
if (state === "starting")
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-gray-100 text-gray-600 dark:bg-dark-700 dark:text-gray-300";
|
||||
}
|
||||
|
||||
function compatibilityClass(
|
||||
status: PluginInstallation["compatibility"]["status"],
|
||||
): string {
|
||||
if (status === "compatible")
|
||||
return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300";
|
||||
if (status === "untested")
|
||||
return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300";
|
||||
return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener("message", handleBridgeMessage);
|
||||
void loadPlugins();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("message", handleBridgeMessage);
|
||||
});
|
||||
</script>
|
||||
@@ -7188,6 +7188,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.settings.features.pluginManagement.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.pluginManagement.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-5 p-6">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{{ t('admin.settings.features.pluginManagement.enabled') }}
|
||||
</label>
|
||||
<p class="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.settings.features.pluginManagement.enabledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<Toggle v-model="form.plugin_management_enabled" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="border-b border-gray-100 px-6 py-4 dark:border-dark-700">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
@@ -9752,6 +9776,8 @@ const form = reactive<SettingsForm>({
|
||||
model_plaza_enabled: false,
|
||||
model_plaza_require_auth: false,
|
||||
model_plaza_description: '',
|
||||
// Plugin management menu visibility; plugin runtime is unaffected.
|
||||
plugin_management_enabled: false,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: false,
|
||||
// Allow user view error requests
|
||||
@@ -11408,6 +11434,7 @@ async function saveSettings() {
|
||||
model_plaza_enabled: form.model_plaza_enabled,
|
||||
model_plaza_require_auth: form.model_plaza_require_auth,
|
||||
model_plaza_description: form.model_plaza_description,
|
||||
plugin_management_enabled: form.plugin_management_enabled,
|
||||
// Affiliate (邀请返利) feature switch
|
||||
affiliate_enabled: form.affiliate_enabled,
|
||||
allow_user_view_error_requests: form.allow_user_view_error_requests,
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import PluginsView from '../PluginsView.vue'
|
||||
|
||||
const {
|
||||
listPlugins,
|
||||
uploadPlugin,
|
||||
enablePlugin,
|
||||
savePluginConfig,
|
||||
createUISession,
|
||||
stepUpRun,
|
||||
} = vi.hoisted(() => ({
|
||||
listPlugins: vi.fn(),
|
||||
uploadPlugin: vi.fn(),
|
||||
enablePlugin: vi.fn(),
|
||||
savePluginConfig: vi.fn(),
|
||||
createUISession: vi.fn(),
|
||||
stepUpRun: vi.fn((action: () => Promise<unknown>) => action()),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminAPI: {
|
||||
plugins: {
|
||||
list: listPlugins,
|
||||
upload: uploadPlugin,
|
||||
enable: enablePlugin,
|
||||
disable: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
getConfig: vi.fn().mockResolvedValue({}),
|
||||
saveConfig: savePluginConfig,
|
||||
test: vi.fn().mockResolvedValue({ success: true, message: 'ok', latency_ms: 1 }),
|
||||
createUISession,
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores', () => ({
|
||||
useAppStore: () => ({
|
||||
showError: vi.fn(),
|
||||
showSuccess: vi.fn(),
|
||||
showInfo: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useStepUp', () => ({
|
||||
useStepUp: () => ({ run: stepUpRun }),
|
||||
isStepUpBlocked: () => false,
|
||||
isStepUpCancelled: () => false,
|
||||
stepUpBlockReason: () => '',
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('vue-i18n')>()),
|
||||
useI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
const plugin = {
|
||||
id: 7,
|
||||
plugin_key: 'local.test.transport',
|
||||
name: 'Test Transport',
|
||||
version: '1.0.0',
|
||||
description: '',
|
||||
author: 'test',
|
||||
manifest: {
|
||||
schema_version: 1,
|
||||
id: 'local.test.transport',
|
||||
name: 'Test Transport',
|
||||
version: '1.0.0',
|
||||
requires: {
|
||||
sub2api: '>=0.1.0',
|
||||
plugin_protocol: 1,
|
||||
transport_api: 1,
|
||||
ui_bridge: 1,
|
||||
},
|
||||
capabilities: [],
|
||||
ui: { entrypoint: 'ui/index.html' },
|
||||
},
|
||||
binary_sha256: 'a'.repeat(64),
|
||||
signature_status: 'trusted' as const,
|
||||
state: 'disabled' as const,
|
||||
last_error: '',
|
||||
installed_at: '2026-08-22T00:00:00Z',
|
||||
updated_at: '2026-08-22T00:00:00Z',
|
||||
bindings: [
|
||||
{
|
||||
id: 1,
|
||||
plugin_id: 7,
|
||||
capability: 'openai.oauth.outbound_transport.v1',
|
||||
platform: 'openai',
|
||||
account_type: 'oauth',
|
||||
enabled: false,
|
||||
rollout_percent: 100,
|
||||
},
|
||||
],
|
||||
compatibility: {
|
||||
compatible: true,
|
||||
tested: true,
|
||||
status: 'compatible' as const,
|
||||
message: '',
|
||||
current_sub2api_version: '0.1.0',
|
||||
required_sub2api_version: '>=0.1.0',
|
||||
recommended_sub2api_version: '0.1.0',
|
||||
plugin_protocol: 1,
|
||||
transport_api: 1,
|
||||
ui_bridge: 1,
|
||||
},
|
||||
runtime_healthy: false,
|
||||
runtime_message: '',
|
||||
}
|
||||
|
||||
function mountView() {
|
||||
return mount(PluginsView, {
|
||||
global: {
|
||||
stubs: {
|
||||
AppLayout: { template: '<div><slot /></div>' },
|
||||
BaseDialog: { template: '<div><slot /></div>' },
|
||||
Icon: true,
|
||||
TotpStepUpDialog: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('管理员插件页二次验证', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
stepUpRun.mockImplementation((action: () => Promise<unknown>) => action())
|
||||
listPlugins.mockResolvedValue([plugin])
|
||||
uploadPlugin.mockResolvedValue(plugin)
|
||||
enablePlugin.mockResolvedValue(plugin)
|
||||
savePluginConfig.mockResolvedValue({ enabled: true })
|
||||
createUISession.mockResolvedValue({
|
||||
url: '/api/v1/plugin-ui/token/index.html#bridge_token=bridge',
|
||||
bridge_token: 'bridge',
|
||||
ui_bridge_version: 1,
|
||||
expires_at: '2026-08-22T01:00:00Z',
|
||||
})
|
||||
})
|
||||
|
||||
it('启用插件通过 step-up 控制器执行', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
|
||||
const button = wrapper.findAll('button').find((item) => item.text().includes('admin.plugins.enable'))
|
||||
expect(button).toBeDefined()
|
||||
await button!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(stepUpRun).toHaveBeenCalledTimes(1)
|
||||
expect(enablePlugin).toHaveBeenCalledWith(7, 100, false)
|
||||
})
|
||||
|
||||
it('上传插件通过 step-up 控制器执行', async () => {
|
||||
const wrapper = mountView()
|
||||
await flushPromises()
|
||||
const input = wrapper.get('input[type="file"]')
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
configurable: true,
|
||||
value: [new File(['plugin'], 'transport.s2plugin', { type: 'application/zip' })],
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
|
||||
expect(stepUpRun).toHaveBeenCalledTimes(1)
|
||||
expect(uploadPlugin).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user