This commit is contained in:
gr
2026-11-04 10:14:23 +08:00
parent f7f323462c
commit b7c3a962dd
759 changed files with 74649 additions and 4421 deletions
@@ -36,6 +36,7 @@ export namespace AuthApi {
}
}
/** ZYT 与客服本地账号共用密码登录;凭证只放 POST body,由服务端选择验证来源。 */
export async function loginApi(data: AuthApi.LoginParams) {
const resp = await requestClient.post<AuthApi.BackendLoginResult>(
'/auth/login',
+18 -9
View File
@@ -17,6 +17,7 @@ import { message } from 'ant-design-vue';
import { t } from '#/locales/t';
import { useAuthStore } from '#/store';
import { requestBelongsToSession } from '#/utils/authorization-sync';
import { refreshTokenApi } from './core';
import { responseErrorMessage } from './error-message';
@@ -109,15 +110,23 @@ function createRequestClient(baseURL: string, options?: RequestClientOptions) {
);
// token过期的处理
client.addResponseInterceptor(
authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
}),
);
const authentication = authenticateResponseInterceptor({
client,
doReAuthenticate,
doRefreshToken,
enableRefreshToken: preferences.app.enableRefreshToken,
formatToken,
});
client.addResponseInterceptor({
...authentication,
rejected: (error) => {
// A delayed 401 from a previous account must not log out the new account.
if (!requestBelongsToSession(error?.config?.headers?.Authorization, useAccessStore().accessToken)) {
return Promise.reject(error);
}
return authentication.rejected?.(error);
},
});
// 通用的错误处理,如果没有进入上面的错误处理逻辑,就会进入这里
client.addResponseInterceptor(
+2
View File
@@ -17,6 +17,7 @@ import { initComponentAdapter } from './adapter/component';
import { initSetupVbenForm } from './adapter/form';
import App from './app.vue';
import { router } from './router';
import { installAuthorizationSync } from './router/authorization-sync';
async function bootstrap(namespace: string) {
// 初始化组件适配器
@@ -57,6 +58,7 @@ async function bootstrap(namespace: string) {
// 配置路由及路由守卫
app.use(router);
app.onUnmount(installAuthorizationSync(router));
// 配置Motion插件
const { MotionPlugin } = await import('@vben/plugins/motion');
@@ -0,0 +1,133 @@
import type { UserInfo } from '@vben/types';
import type { RouteRecordRaw, Router } from 'vue-router';
import { watch } from 'vue';
import { createMemoryHistory, createRouter } from 'vue-router';
import { getTabKey, useAccessStore, useTabbarStore, useUserStore } from '@vben/stores';
import { getUserInfoApi } from '#/api/core/user';
import { createAuthorizationSync } from '#/utils/authorization-sync';
import { generateAccess } from './access';
import { accessRoutes, routes } from './routes';
function copyRoutes(input: RouteRecordRaw[]): RouteRecordRaw[] {
return input.map((route) => ({ ...route, meta: { ...route.meta },
children: route.children ? copyRoutes(route.children) : undefined } as RouteRecordRaw));
}
// Vben adds dynamic children into the normalized Root. Keep a clean template,
// including component functions, rather than cloning that mutated tree later.
const staticRoutes = copyRoutes(routes).map((route) => route.path === '/' ? { ...route, children: [] } : route);
const installations = new WeakMap<Router, () => void>();
function routeNames(input: RouteRecordRaw[]): Set<string | symbol> {
return new Set(input.flatMap((route) => [
...(route.name ? [route.name] : []), ...routeNames(route.children ?? []),
]));
}
export function isAuthorizedPath(router: Router, path: string, codes: string[]) {
const target = router.resolve(path);
return target.matched.length > 0 && target.name !== 'FallbackNotFound' && target.matched.every((record) => {
const authority = record.meta.authority;
return !Array.isArray(authority) || authority.length === 0 || authority.some((code) => codes.includes(code));
});
}
/** Reconcile by name/path, retaining authorized matcher/component instances. */
export function reconcileAccessRoutes(router: Router, nextRoutes: RouteRecordRaw[]) {
const staticNames = routeNames(staticRoutes);
const desired = routeNames(nextRoutes);
const root = router.getRoutes().find((route) => route.path === '/');
if (!root?.name) throw new Error('Missing application root route');
for (const record of router.getRoutes()) {
if (record.name && !staticNames.has(record.name) && !desired.has(record.name)) router.removeRoute(record.name);
}
function install(input: RouteRecordRaw[], parent: string | symbol) {
for (const route of input) {
const parentPath = router.getRoutes().find((record) => record.name === parent)?.path ?? '/';
const expectedPath = route.path.startsWith('/') ? route.path : `${parentPath.replace(/\/$/, '')}/${route.path}`;
let existing = route.name ? router.getRoutes().find((record) => record.name === route.name) : undefined;
if (existing && existing.path !== expectedPath) {
router.removeRoute(existing.name!);
existing = undefined;
}
if (existing) {
// Existing authorized pages keep their resolved components and drafts.
// Recurse so a grant adds missing siblings without replacing their parent.
Object.assign(existing.meta, route.meta);
install(route.children ?? [], existing.name!);
} else if (route.meta?.noBasicLayout) {
router.addRoute(route);
} else {
router.addRoute(parent, route);
}
}
}
install(nextRoutes, root.name);
// Keep Vben's source tree exact as well as Vue Router's matcher table.
root.children.splice(0, root.children.length, ...nextRoutes.filter((route) => !route.meta?.noBasicLayout));
}
export function pruneAuthorizationTabs(router: Router, codes: string[], tabbar: ReturnType<typeof useTabbarStore>) {
tabbar.tabs = tabbar.tabs.filter((tab) => isAuthorizedPath(router, tab.fullPath || tab.path, codes));
tabbar.visitHistory.retain(tabbar.tabs.map((tab) => tab.key || getTabKey(tab)));
for (const [key, cached] of tabbar.cachedRoutes) {
if (!isAuthorizedPath(router, cached.route.fullPath || cached.route.path, codes)) tabbar.removeCachedRoute(key);
}
for (const name of tabbar.excludeCachedTabs) {
if (!router.hasRoute(name)) tabbar.excludeCachedTabs.delete(name);
}
void tabbar.updateCacheTabs();
}
/** Install once for this app, after Pinia and Router are available. */
export function installAuthorizationSync(router: Router) {
installations.get(router)?.();
const access = useAccessStore();
const user = useUserStore();
const tabbar = useTabbarStore();
const sync = createAuthorizationSync({
window, document,
fetch: getUserInfoApi,
getCodes: () => access.accessCodes,
getSession: () => access.accessToken && access.isAccessChecked && user.userInfo?.userId
? { token: access.accessToken, userId: String(user.userInfo.userId) } : undefined,
async prepare(info: UserInfo) {
const preparedRouter = createRouter({ history: createMemoryHistory(), routes: copyRoutes(staticRoutes) });
return generateAccess({ roles: info.roles ?? [], router: preparedRouter, routes: accessRoutes });
},
apply(info, snapshot) {
const codes = info.roles ?? [];
user.setUserInfo(info);
access.setAccessCodes(codes);
if (!snapshot) return;
reconcileAccessRoutes(router, snapshot.accessibleRoutes);
access.setAccessMenus(snapshot.accessibleMenus);
access.setAccessRoutes(snapshot.accessibleRoutes);
pruneAuthorizationTabs(router, codes, tabbar);
// Keep an authorized current component mounted; replacing its route would
// discard unsaved forms merely because a different permission changed.
if (!isAuthorizedPath(router, router.currentRoute.value.fullPath, codes)) {
return router.replace('/group/welcome').then(() => {});
}
},
});
const unwatch = watch(() => JSON.stringify([
access.accessToken, user.userInfo?.userId, access.isAccessChecked,
]), () => {
sync.stop();
if (access.accessToken && user.userInfo?.userId && access.isAccessChecked) sync.start();
}, { immediate: true, flush: 'sync' });
const dispose = () => {
unwatch();
sync.stop();
if (installations.get(router) === dispose) installations.delete(router);
};
installations.set(router, dispose);
return dispose;
}
@@ -40,7 +40,6 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:bot',
order: 0,
title: 'AI 模型',
authority: ['model:read', 'agent:read', 'stats:read'],
},
name: 'ModelCenter',
path: '/model',
@@ -94,7 +93,6 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:monitor-smartphone',
order: 1,
title: '桌面端',
authority: ['desktop-account:read', 'config:read'],
},
name: 'DesktopCenter',
path: '/desktop',
@@ -138,7 +136,6 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:messages-square',
order: 2,
title: '聊天归档',
authority: ['im:read', 'im:content:read', 'im:export', 'im:identity:write', 'im:storage:write'],
},
name: 'ArchiveCenter',
path: '/archive',
@@ -200,7 +197,6 @@ const routes: RouteRecordRaw[] = [
icon: 'lucide:shield',
order: 3,
title: '系统管理',
authority: ['user:read', 'audit:read'],
},
name: 'SystemCenter',
path: '/system',
@@ -212,7 +208,7 @@ const routes: RouteRecordRaw[] = [
meta: {
icon: 'lucide:key-round',
title: '角色权限',
authority: ['role:read', 'role:write'],
authority: ['role:read', 'user:read'],
},
},
{
@@ -262,4 +258,12 @@ const routes: RouteRecordRaw[] = [
},
];
// A business directory is visible when at least one of its pages is granted.
// Derive its codes so newly split page permissions cannot be hidden by stale parents.
for (const route of routes) {
if (route.meta && route.children?.length && route.children.every(child => child.meta?.authority?.length)) {
route.meta.authority = [...new Set(route.children.flatMap(child => child.meta?.authority || []))];
}
}
export default routes;
@@ -0,0 +1,91 @@
interface AuthorizationInfo {
roles?: string[];
userId: string;
}
interface Session {
token: string;
userId: string;
}
interface SyncOptions<Info extends AuthorizationInfo, Snapshot> {
apply: (info: Info, snapshot?: Snapshot) => Promise<void> | void;
document: Pick<Document, 'addEventListener' | 'removeEventListener' | 'visibilityState'>;
fetch: () => Promise<Info>;
getCodes: () => string[];
getSession: () => Session | undefined;
interval?: number;
prepare: (info: Info) => Promise<Snapshot>;
window: Pick<Window, 'addEventListener' | 'clearInterval' | 'removeEventListener' | 'setInterval'>;
}
function permissionKey(codes: string[]) {
return JSON.stringify([...new Set(codes)].sort());
}
/** Ignore failures from a request made by a different, already-ended session. */
export function requestBelongsToSession(authorization: unknown, token: null | string) {
return (authorization || null) === (token ? `Bearer ${token}` : null);
}
/** One foreground poller. Preparation never mutates the displayed router/stores. */
export function createAuthorizationSync<Info extends AuthorizationInfo, Snapshot>(options: SyncOptions<Info, Snapshot>) {
let active = false;
let epoch = 0;
let timer: number | undefined;
let pending: Promise<void> | undefined;
let appliedPermissions = permissionKey(options.getCodes());
function refresh(): Promise<void> {
if (!active || options.document.visibilityState !== 'visible') return Promise.resolve();
if (pending) return pending;
const session = options.getSession();
if (!session) return Promise.resolve();
const generation = epoch;
const current = () => {
const now = options.getSession();
return active && epoch === generation && now?.token === session.token && now.userId === session.userId;
};
const task = (async () => {
try {
const info = await options.fetch();
if (!current() || String(info.userId) !== session.userId) return;
const changed = permissionKey(info.roles ?? []) !== appliedPermissions;
const snapshot = changed ? await options.prepare(info) : undefined;
if (!current()) return;
await options.apply(info, snapshot);
if (current()) appliedPermissions = permissionKey(info.roles ?? []);
} catch {
// requestClient owns HTTP/401 handling. A later foreground poll can recover;
// failed background reads must never clear grants or replay a write.
}
})();
pending = task;
void task.finally(() => { if (pending === task) pending = undefined; });
return task;
}
const trigger = () => { void refresh(); };
function stop() {
active = false;
epoch += 1;
pending = undefined;
if (timer !== undefined) options.window.clearInterval(timer);
timer = undefined;
options.window.removeEventListener('focus', trigger);
options.document.removeEventListener('visibilitychange', trigger);
}
function start() {
if (active) return;
active = true;
epoch += 1;
appliedPermissions = permissionKey(options.getCodes());
options.window.addEventListener('focus', trigger);
options.document.addEventListener('visibilitychange', trigger);
timer = options.window.setInterval(trigger, options.interval ?? 3000);
trigger();
}
return { refresh, start, stop };
}
@@ -42,6 +42,15 @@ export function validateGroupConfig(config: GroupConfig, origin: string): GroupC
return config as GroupConfig & Required<Omit<GroupConfig, 'enabled'>>;
}
/** Reach the configured callback origin before storing its tab-bound PKCE proof. */
export function groupLoginEntry(config: GroupConfig, origin: string): string {
const callback = safeUrl(config?.redirect_uri);
validateGroupConfig(config, callback.origin);
const current = safeUrl(origin);
if (current.href !== `${current.origin}/`) throw new Error(t('集团登录地址配置无效'));
return callback.origin === current.origin ? '/auth/group-login' : callback.href;
}
function base64url(value: Uint8Array): string {
return btoa(String.fromCharCode(...value)).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '');
}
@@ -6,7 +6,7 @@ import { Alert, Button, Spin } from 'ant-design-vue';
import { getGroupConfigApi } from '#/api';
import { useAuthStore } from '#/store';
import { beginGroupLogin, consumeGroupCallback, GROUP_TRANSACTION_KEY } from '#/utils/group-sso';
import { beginGroupLogin, consumeGroupCallback, groupLoginEntry, GROUP_TRANSACTION_KEY } from '#/utils/group-sso';
const error = ref('');
const router = useRouter();
@@ -24,7 +24,13 @@ onMounted(async () => {
await auth.authGroupLogin(transaction);
} else {
sessionStorage.removeItem(GROUP_TRANSACTION_KEY);
const target = await beginGroupLogin(await getGroupConfigApi());
const config = await getGroupConfigApi();
const entry = groupLoginEntry(config, window.location.origin);
if (entry !== '/auth/group-login') {
window.location.replace(entry);
return;
}
const target = await beginGroupLogin(config);
window.location.assign(target);
}
} catch {
@@ -1,14 +1,9 @@
<script lang="ts" setup>
import type { VbenFormSchema } from '@vben/common-ui';
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { computed } from 'vue';
import { AuthenticationLogin, z } from '@vben/common-ui';
import { Button } from 'ant-design-vue';
import { getGroupConfigApi } from '#/api';
import { validateGroupConfig } from '#/utils/group-sso';
import { t } from '#/locales/t';
import { useAuthStore } from '#/store';
@@ -16,14 +11,6 @@ import { useAuthStore } from '#/store';
defineOptions({ name: 'Login' });
const authStore = useAuthStore();
const router = useRouter();
const groupEnabled = ref(false);
onMounted(async () => {
try {
validateGroupConfig(await getGroupConfigApi(), window.location.origin);
groupEnabled.value = true;
} catch { groupEnabled.value = false; }
});
// 模板自带的演示账号下拉、滑块验证码、手机号/扫码/第三方登录、注册和找回密码
// 都删掉了——后端没有对应的接口,留着只会让人点进死路。滑块验证码尤其要注意:
@@ -31,14 +18,14 @@ onMounted(async () => {
const formSchema = computed((): VbenFormSchema[] => [
{
component: 'VbenInput',
componentProps: { placeholder: t('用户名') },
componentProps: { placeholder: t('ZYT 账号或客服本地账号') },
fieldName: 'username',
label: t('用户名'),
rules: z.string().min(1, { message: t('请输入用户名') }),
label: t('账号'),
rules: z.string().min(1, { message: t('请输入账号') }),
},
{
component: 'VbenInputPassword',
componentProps: { placeholder: t('密码') },
componentProps: { placeholder: t('ZYT 密码或客服本地密码') },
fieldName: 'password',
label: t('密码'),
rules: z.string().min(1, { message: t('请输入密码') }),
@@ -54,15 +41,10 @@ const formSchema = computed((): VbenFormSchema[] => [
:show-forget-password="false"
:show-qrcode-login="false"
:show-register="false"
:show-remember-me="false"
:show-third-party-login="false"
:sub-title="t('请使用管理员分配的账号登录')"
:sub-title="t('ZYT 首次登录为基础用户,业务权限由客服管理员分配。')"
:title="t('真羊 AI 客服 · 管理后台')"
@submit="authStore.authLogin"
>
<template #third-party-login>
<Button v-if="groupEnabled" block class="mt-4" @click="router.push('/auth/group-login')">
{{ t('使用集团账号登录') }}
</Button>
</template>
</AuthenticationLogin>
/>
</template>
@@ -27,8 +27,9 @@ const open = ref(false);
const isNew = ref(false);
const form = reactive({ code: '', name: '', permissions: [] as string[] });
const protectedRole = (code: string) => code === 'admin' || code === 'zyt_basic';
// 新增和修改是两个按钮:只能新增的人打开已有角色只能看
const readonly = computed(() => !(isNew.value ? canCreate.value : canWrite.value) || form.code === 'admin');
const readonly = computed(() => !(isNew.value ? canCreate.value : canWrite.value) || protectedRole(form.code));
/** 一级菜单 → 页面 → 权限码,和左侧菜单一一对应。 */
const grouped = computed(() => groupPermissions(permissions.value));
@@ -49,6 +50,7 @@ function expandAll() {
}
function onCheck(keys: unknown) {
if (readonly.value) return;
// 不是严格模式时 antd 给的是数组;兜底处理 { checked } 形状
const list = Array.isArray(keys) ? keys : ((keys as { checked?: (number | string)[] }).checked ?? []);
form.permissions = applyTreeCheck(form.permissions, list, visibleCodes.value, permissions.value);
@@ -98,10 +100,11 @@ async function submit() {
const known = new Set(permissions.value.map((item) => item.code));
roles.value = (await saveRole({ ...form, permissions: form.permissions.filter((code) => known.has(code)) })).roles;
open.value = false;
message.success(t('已保存,权限立即生效(无需重启,也无需重新登录)'));
message.success(t('已保存,后端权限立即生效;在线用户的菜单和按钮将在数秒内自动更新。'));
}
async function remove(row: RoleItem) {
if (!canDelete.value || protectedRole(row.code) || row.builtin || row.user_count > 0) return;
roles.value = (await deleteRole(row.code)).roles;
message.success(t('已删除'));
}
@@ -125,7 +128,7 @@ onMounted(load);
type="info"
show-icon
:message="t('权限按左侧菜单组织:菜单 → 页面 → 页面里的每个按钮')"
:description="t('改完立刻生效——不用重启服务,用户也不用重新登录。管理员角色不可削权(削了就没人能加回来),内置角色和仍有人在用的角色不可删除。要新增权限点或左侧菜单链接,去「系统管理 → 权限菜单」。')"
:description="t('后端权限修改后立即生效,无需重启或重新登录;在线用户的菜单和按钮将在数秒内自动更新,切回窗口时立即同步。管理员和集团基础用户角色只读,内置角色和仍有人在用的角色不可删除。要新增权限点或左侧菜单链接,去「系统管理 → 权限菜单」。')"
/>
<Card :title="t('角色与权限')">
<template #extra>
@@ -158,12 +161,12 @@ onMounted(load);
</template>
<template v-else-if="column.key === 'action'">
<Button type="link" size="small" @click="openEdit(record as RoleItem)">
{{ canWrite && record.code !== 'admin' ? t('编辑') : t('查看') }}
{{ canWrite && !protectedRole(record.code) ? t('编辑') : t('查看') }}
</Button>
<Popconfirm :title="t('确定删除这个角色?')" @confirm="remove(record as RoleItem)">
<Button
type="link" size="small" danger
:disabled="!canDelete || record.builtin || record.user_count > 0"
:disabled="!canDelete || protectedRole(record.code) || record.builtin || record.user_count > 0"
>
{{ t('删除') }}
</Button>
@@ -190,6 +193,9 @@ onMounted(load);
<p v-if="form.code === 'admin'" class="mb-2 text-xs text-gray-500">
{{ t('管理员始终拥有全部权限(包括以后新增的),不能修改。') }}
</p>
<p v-else-if="form.code === 'zyt_basic'" class="mb-2 text-xs text-gray-500">
{{ t('集团基础用户固定无业务权限,不能修改。请到「系统管理 → 用户管理」为账号分配其他业务角色。') }}
</p>
<div class="mb-2 flex flex-wrap items-center gap-2">
<Input v-model:value="keyword" allow-clear size="small" class="w-60" :placeholder="t('搜索按钮名称或权限码')"
@change="expandAll" />
@@ -0,0 +1,21 @@
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import vm from 'node:vm';
import test from 'node:test';
const require=createRequire(import.meta.url);
const ts=require(process.env.KEFU_SSO_TYPESCRIPT||'typescript');
test('实际Vite代理同时接受旧前缀与标准API前缀,保留独立后端配置',async()=>{
const source=readFileSync(new URL('../vite.config.ts',import.meta.url),'utf8');
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
for(const target of [undefined,'http://127.0.0.1:18766']) {
const module={exports:{}};
vm.runInNewContext(code,{module,exports:module.exports,process:{env:{VITE_API_TARGET:target}},
require(name){assert.equal(name,'@vben/vite-config');return {defineConfig:factory=>factory};}});
const proxy=(await module.exports.default()).vite.server.proxy['/api'];
assert.equal(proxy.rewrite('/api/auth/login'),'/api/v2/auth/login');
assert.equal(proxy.rewrite('/api/v2/auth/group/config'),'/api/v2/auth/group/config');
assert.equal(proxy.rewrite('/api/v2/users?active=true'),'/api/v2/users?active=true');
assert.equal(proxy.target,target||'http://127.0.0.1:8766');
}
});
@@ -0,0 +1,327 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vm from 'node:vm';
const require = createRequire(process.env.KEFU_SSO_TYPESCRIPT
? pathToFileURL(resolve(process.env.KEFU_SSO_TYPESCRIPT, 'package.json'))
: new URL('../package.json', import.meta.url));
const ts = require('typescript');
const vue = require('vue');
const pinia = require('pinia');
const routing = require('vue-router');
const workspace = process.env.KEFU_SSO_WORKSPACE || fileURLToPath(new URL('../../../', import.meta.url));
const source = relative => readFile(resolve(workspace, relative), 'utf8');
const staged = relative => readFile(new URL(`../src/${relative}`, import.meta.url), 'utf8');
const normalized = value => JSON.parse(JSON.stringify(value));
const clone = value => Array.isArray(value) ? value.map(clone)
: value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([key, item]) => [key, clone(item)])) : value;
function load(code, dependencies = {}, globals = {}) {
const compiled = ts.transpileModule(code.replaceAll('import.meta.hot', 'undefined'), { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
}, reportDiagnostics: true });
assert.deepEqual(compiled.diagnostics, []);
const module = { exports: {} };
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require(name) { assert.ok(name in dependencies, `Unexpected import ${name}`); return dependencies[name]; },
console, ...globals });
return module.exports;
}
const preferences = { app: { accessMode: 'frontend', loginExpiredMode: 'modal', enableRefreshToken: false },
tabbar: { visitHistory: true, maxCount: 0 } };
const tree = load(await source('packages/@core/base/shared/src/utils/tree.ts'));
const stack = load(await source('packages/@core/base/shared/src/utils/stack.ts'));
const { useAccessStore } = load(await source('packages/stores/src/modules/access.ts'), { pinia });
const { useUserStore } = load(await source('packages/stores/src/modules/user.ts'), { pinia });
const { useTabbarStore, getTabKey } = load(await source('packages/stores/src/modules/tabbar.ts'), {
vue, pinia, '@vben-core/preferences': { preferences },
'@vben-core/shared/utils': { ...stack, startProgress() {}, stopProgress() {}, openWindow() {} },
}, { sessionStorage: {} });
const stores = { useAccessStore, useUserStore, useTabbarStore, getTabKey };
const { useAccess } = load(await source('packages/effects/access/src/use-access.ts'), {
vue, '@vben/preferences': { preferences }, '@vben/stores': stores,
});
const { generateRoutesByFrontend } = load(await source('packages/utils/src/helpers/generate-routes-frontend.ts'), {
'@vben-core/shared/utils': tree,
});
const { generateMenus } = load(await source('packages/utils/src/helpers/generate-menus.ts'), {
'@vben-core/shared/utils': tree,
});
const { generateAccessible } = load(await source('packages/effects/access/src/accessible.ts'), {
vue, '@vben/utils': { ...tree, cloneDeep: clone, generateMenus, generateRoutesByFrontend,
generateRoutesByBackend() { throw new Error('Unexpected backend access mode'); },
isFunction: value => typeof value === 'function', isString: value => typeof value === 'string' },
});
const syncModule = load(await staged('utils/authorization-sync.ts'));
const menuHelpers = load(await source('apps/web-antd/src/router/menu-access.ts'));
const originalRoutes = load(await staged('router/routes/modules/console.ts')).default;
function pageRoutes(input) {
return input.map(route => ({ ...route, meta: { ...route.meta },
...(route.component ? { component: { name: `Test${route.name}`, render: () => null } } : {}),
children: route.children ? pageRoutes(route.children) : undefined }));
}
const accessRoutes = pageRoutes(originalRoutes);
const routes = [
{ name: 'Root', path: '/', component: { render: () => vue.h(routing.RouterView) }, children: [] },
{ name: 'Login', path: '/auth/login', component: { render: () => null } },
{ name: 'FallbackNotFound', path: '/:path(.*)*', component: { render: () => null } },
];
function environment() {
const window = new EventTarget();
const document = new EventTarget();
document.visibilityState = 'visible';
const timers = new Map();
let sequence = 0;
window.setInterval = (callback, delay) => { const id = ++sequence; timers.set(id, { callback, delay }); return id; };
window.clearInterval = id => timers.delete(id);
return { window, document, timers,
tick() { for (const timer of [...timers.values()]) timer.callback(); },
focus() { window.dispatchEvent(new Event('focus')); },
visible(value) { document.visibilityState = value ? 'visible' : 'hidden'; document.dispatchEvent(new Event('visibilitychange')); } };
}
const settle = () => new Promise(resolve => setImmediate(resolve));
const info = (roles = [], userId = '7') => ({ userId, username: `user${userId}`, realName: 'Test', roles });
const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; };
async function harness(initial = []) {
pinia.setActivePinia(pinia.createPinia());
const access = useAccessStore();
const user = useUserStore();
const tabbar = useTabbarStore();
const env = environment();
const router = routing.createRouter({ history: routing.createMemoryHistory(), routes: clone(routes) });
const requests = [];
let answer = info(initial);
let preparations = 0;
const generateAccess = async options => {
preparations += 1;
const generated = await generateAccessible('frontend', options);
return { ...generated, accessibleMenus: menuHelpers.pruneEmptyMenus(generated.accessibleMenus, menuHelpers.parentPaths(options.routes)) };
};
const adapter = load(await staged('router/authorization-sync.ts'), {
vue, 'vue-router': routing, '@vben/stores': stores,
'#/api/core/user': { getUserInfoApi() { requests.push('GET /me'); return Promise.resolve(typeof answer === 'function' ? answer() : clone(answer)); } },
'#/utils/authorization-sync': syncModule, './access': { generateAccess },
'./routes': { accessRoutes, routes: clone(routes) },
}, env);
access.setAccessToken('same-session');
access.setAccessCodes(initial);
user.setUserInfo(info(initial));
const first = await generateAccess({ roles: initial, routes: accessRoutes, router });
access.setAccessMenus(first.accessibleMenus);
access.setAccessRoutes(first.accessibleRoutes);
access.setIsAccessChecked(true);
await router.push('/group/welcome');
const dispose = adapter.installAuthorizationSync(router);
await settle();
return { access, user, tabbar, env, router, requests, adapter, dispose,
preparations: () => preparations,
answer(value) { answer = value; },
async poll(value) { answer = value; env.tick(); await settle(); },
};
}
test('same logged-in token gains/revokes real menus, matcher routes and reactive action rights', async () => {
const h = await harness();
try {
const { hasAccessByCodes } = useAccess();
const createButton = vue.computed(() => hasAccessByCodes(['role:create']));
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(createButton.value, false);
await h.poll(info(['role:read']));
assert.equal(h.router.hasRoute('RoleManage'), true);
assert.ok(h.access.accessMenus.some(menu => menu.children?.some(item => item.path === '/system/roles')));
await h.router.push('/system/roles');
const current = h.router.currentRoute.value;
const record = h.router.getRoutes().find(route => route.name === 'RoleManage');
const component = record.components.default;
const cachedDraft = { component: vue.markRaw({ draft: 'unsaved role form' }), key: '/system/roles', route: vue.markRaw(current) };
h.tabbar.cachedRoutes.set('/system/roles', cachedDraft);
await h.poll(info(['role:read', 'role:create']));
assert.equal(createButton.value, true);
assert.equal(h.router.currentRoute.value, current, 'authorized current route was navigated/replaced');
assert.equal(h.router.getRoutes().find(route => route.name === 'RoleManage').components.default, component);
assert.equal(h.tabbar.cachedRoutes.get('/system/roles').component.draft, 'unsaved role form');
await h.poll(info(['role:read']));
assert.equal(createButton.value, false);
assert.equal(h.router.currentRoute.value, current);
assert.equal(h.access.accessToken, 'same-session');
await h.poll(info([]));
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(h.router.hasRoute('SystemCenter'), false, 'fully revoked branch survived');
assert.equal(h.router.resolve('/system/roles').name, 'FallbackNotFound');
assert.ok(!h.access.accessMenus.some(menu => menu.path === '/system'));
assert.equal(h.router.currentRoute.value.path, '/group/welcome');
assert.equal(h.access.accessToken, 'same-session');
assert.equal(h.tabbar.cachedRoutes.has('/system/roles'), false);
} finally { h.dispose(); }
});
test('grant/revoke siblings preserves an authorized current matcher, component and pinned tab', async () => {
const h = await harness(['user:read']);
try {
await h.router.push('/system/users');
const current = h.router.currentRoute.value;
const components = h.router.getRoutes().find(route => route.name === 'UserManage').components;
h.tabbar.addTab({ ...current, key: '/system/users', meta: { ...current.meta, affixTab: true, keepAlive: true } });
await h.poll(info(['user:read', 'audit:read']));
assert.equal(h.router.hasRoute('AuditLog'), true);
assert.equal(h.router.currentRoute.value, current);
assert.equal(h.router.getRoutes().find(route => route.name === 'UserManage').components, components);
const audit = h.router.resolve('/system/audit');
h.tabbar.addTab({ ...audit, key: '/system/audit', meta: { ...audit.meta, affixTab: true, keepAlive: true } });
h.tabbar.cachedRoutes.set('/system/audit', { key: '/system/audit', route: vue.markRaw(audit), component: vue.markRaw({}) });
await h.poll(info(['user:read']));
assert.equal(h.router.hasRoute('AuditLog'), false);
assert.equal(h.router.currentRoute.value, current);
assert.deepEqual(normalized(h.tabbar.tabs.map(tab => tab.path)), ['/system/users']);
assert.equal(h.tabbar.cachedTabs.has('AuditLog'), false);
assert.equal(h.tabbar.cachedRoutes.has('/system/audit'), false);
assert.equal(h.tabbar.visitHistory.size, 1);
} finally { h.dispose(); }
});
test('equal/reordered permissions refresh profile without rebuilding menus; hidden polls pause and focus resumes', async () => {
const h = await harness(['role:read', 'role:create']);
try {
const before = h.preparations();
await h.poll({ ...info(['role:create', 'role:read', 'role:read']), realName: 'Changed display name' });
assert.equal(h.preparations(), before);
assert.equal(h.user.userInfo.realName, 'Changed display name');
assert.deepEqual([...h.env.timers.values()].map(timer => timer.delay), [3000]);
const requests = h.requests.length;
h.env.visible(false);
h.env.tick();
h.env.focus();
await settle();
assert.equal(h.requests.length, requests);
h.answer(info([]));
h.env.visible(true);
await settle();
assert.equal(h.router.hasRoute('RoleManage'), false);
assert.equal(h.requests.length, requests + 1);
h.answer(info(['role:read']));
h.env.focus();
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
h.dispose();
const disposed = h.requests.length;
h.env.tick(); h.env.focus(); h.env.visible(true);
await settle();
assert.equal(h.env.timers.size, 0);
assert.equal(h.requests.length, disposed);
} finally { h.dispose(); }
});
test('concurrent events coalesce; logout disposes; old account response cannot overwrite a new session', async () => {
const h = await harness();
try {
const old = deferred();
h.answer(() => old.promise);
const before = h.requests.length;
h.env.tick(); h.env.focus(); h.env.visible(true); h.env.tick();
assert.equal(h.requests.length, before + 1);
h.access.setAccessToken(null);
assert.equal(h.env.timers.size, 0);
h.user.setUserInfo(info([], '8'));
h.access.setAccessCodes([]);
h.answer(info(['role:read'], '8'));
h.access.setAccessToken('new-session');
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
old.resolve(info(['user:read', 'role:create'], '7'));
await settle();
assert.equal(h.access.accessToken, 'new-session');
assert.equal(h.user.userInfo.userId, '8');
assert.deepEqual(normalized(h.access.accessCodes), ['role:read']);
assert.equal(h.router.hasRoute('UserManage'), false);
assert.equal(h.env.timers.size, 1);
} finally { h.dispose(); }
});
test('wrong identity and late preparation are ignored; failed preparation can recover', async () => {
const env = environment();
let session = { token: 'A', userId: '7' };
let answer = info(['role:read'], '8');
const stagedSnapshot = deferred();
let prepares = 0, applied = 0;
let prepare = async () => { prepares += 1; return stagedSnapshot.promise; };
const sync = syncModule.createAuthorizationSync({ ...env, getSession: () => session, getCodes: () => [],
fetch: async () => answer, prepare: value => prepare(value), apply() { applied += 1; } });
sync.start();
await sync.refresh();
assert.equal(prepares, 0);
answer = info(['role:read']);
const pending = sync.refresh();
await Promise.resolve();
session = { token: 'B', userId: '9' };
stagedSnapshot.resolve({});
await pending;
assert.equal(applied, 0);
sync.stop();
session = { token: 'A', userId: '7' };
prepare = async () => { throw new Error('Temporary menu read failure'); };
sync.start();
await sync.refresh();
assert.equal(applied, 0);
prepare = async () => ({});
await sync.refresh();
assert.equal(applied, 1);
sync.stop();
});
test('reinstall is singleton and an old disposer cannot stop its replacement', async () => {
const h = await harness();
const replacement = h.adapter.installAuthorizationSync(h.router);
try {
h.dispose();
await settle();
assert.equal(h.env.timers.size, 1);
h.answer(info(['role:read']));
h.env.focus();
await settle();
assert.equal(h.router.hasRoute('RoleManage'), true);
} finally { replacement(); }
assert.equal(h.env.timers.size, 0);
});
test('actual request auth interceptor ignores an old 401; current 401 retains expiry handling; 403 never retries', async () => {
pinia.setActivePinia(pinia.createPinia());
const access = useAccessStore();
access.setAccessToken('new-session'); access.setIsAccessChecked(true);
const clients = [];
class RequestClient {
constructor() { this.responses = []; clients.push(this); }
addRequestInterceptor() {}
addResponseInterceptor(value) { this.responses.push(value); }
}
const preset = load(await source('packages/effects/request/src/request-client/preset-interceptors.ts'), {
'@vben/locales': { $t: key => key }, '@vben/utils': { isFunction: value => typeof value === 'function' }, axios: require('axios'),
});
let logouts = 0;
load((await staged('api/request.ts')).replaceAll('import.meta.env.PROD', 'false').replaceAll('import.meta.env', '{}'), {
'@vben/hooks': { useAppConfig: () => ({ apiURL: '/api/v2' }) },
'@vben/preferences': { preferences }, '@vben/request': { ...preset, RequestClient }, '@vben/stores': stores,
'ant-design-vue': { message: { error() {} } }, '#/locales/t': { t: key => key },
'#/store': { useAuthStore: () => ({ logout: async () => { logouts += 1; } }) },
'#/utils/authorization-sync': syncModule, './core': { refreshTokenApi: async () => { throw new Error('No refresh token'); } },
'./error-message': { responseErrorMessage: () => 'Error' },
});
const authentication = clients[0].responses[1];
const error = (status, token) => ({ config: { headers: { Authorization: `Bearer ${token}` } }, response: { status } });
await assert.rejects(authentication.rejected(error(401, 'old-session')));
assert.equal(access.accessToken, 'new-session');
assert.equal(access.loginExpired, false);
await assert.rejects(authentication.rejected(error(403, 'new-session')));
assert.equal(access.accessToken, 'new-session');
assert.equal(logouts, 0);
await assert.rejects(authentication.rejected(error(401, 'new-session')));
assert.equal(access.accessToken, null);
assert.equal(access.loginExpired, true);
});
@@ -12,6 +12,9 @@ const engine=await readFile(enginePath,'utf8');
const tree=ts.createSourceFile('engine.ts',engine,ts.ScriptTarget.Latest,true);
const functions=tree.statements.filter(node=>ts.isFunctionDeclaration(node)&&['hasAuthority','menuHasVisibleWithForbidden'].includes(node.name?.text)).map(node=>node.getText(tree)).join('\n');
const {hasAuthority}=await import(moduleUrl(compile(functions+'\nexport {hasAuthority};')));
const sharedPath=process.env.KEFU_SSO_WORKSPACE?pathToFileURL(resolve(process.env.KEFU_SSO_WORKSPACE,'packages/@core/base/shared/src/utils/tree.ts')):new URL('../../../packages/@core/base/shared/src/utils/tree.ts',import.meta.url);
const shared=moduleUrl(compile(await readFile(sharedPath,'utf8')));
const {generateRoutesByFrontend}=await import(moduleUrl(compile(engine).replace("from '@vben-core/shared/utils'",`from '${shared}'`)));
test('基础角色只显示我的账号,业务目录使用实际Vben权限算法按子页面读权限开放',()=>{
const visible=permissions=>routes.filter(route=>!route.meta?.hideInMenu&&hasAuthority(route,permissions)).map(route=>route.name);
assert.deepEqual(visible([]),['GroupAccount']);
@@ -22,3 +25,31 @@ test('基础角色只显示我的账号,业务目录使用实际Vben权限算
const all=[...new Set(routes.flatMap(route=>(route.children||[]).flatMap(child=>child.meta?.authority||[])))];
assert.equal(visible(all).length,6);
});
test('角色读取权限经过实际Vben父子路由过滤后可进入角色页,兼容用户读取权限',async()=>{
for(const permissions of [['role:read'],['role:read','role:write'],['user:read']]){
const filtered=await generateRoutesByFrontend(structuredClone(routes.map(({component,...route})=>({...route,children:route.children?.map(({component,...child})=>child)}))),permissions);
const system=filtered.find(route=>route.name==='SystemCenter');
assert.ok(system,permissions.join(','));
assert.ok(system.children.some(route=>route.name==='RoleManage'),permissions.join(','));
if(!permissions.includes('user:read')) assert.ok(!system.children.some(route=>route.name==='UserManage'));
}
for(const permissions of [[],['role:write'],['role:create'],['role:delete']]){
const filtered=await generateRoutesByFrontend(structuredClone(routes.map(({component,...route})=>({...route,children:route.children?.map(({component,...child})=>child)}))),permissions);
assert.ok(!filtered.some(route=>route.name==='SystemCenter'),permissions.join(','));
}
});
test('每个已授予的页面都能通过实际父目录过滤,包括新增的细分读取权限',async()=>{
for(const parent of routes.filter(route=>!route.meta?.hideInMenu)){
for(const child of parent.children||[]){
for(const code of child.meta?.authority||[]){
const input=routes.map(({component,...route})=>({...route,children:route.children?.map(({component,...page})=>page)}));
const filtered=await generateRoutesByFrontend(structuredClone(input),[code]);
const directory=filtered.find(route=>route.name===parent.name);
assert.ok(directory,`${code}: ${parent.name}`);
assert.ok(directory.children.some(page=>page.name===child.name),`${code}: ${child.name}`);
}
}
}
});
@@ -29,6 +29,19 @@ const origin = 'http://127.0.0.1:5666';
const now = 1_000_000;
const code = 'C'.repeat(43);
test('不同访问地址先进入固定客服回调域,再在该域创建PKCE事务', async () => {
assert.equal(sso.groupLoginEntry(config, origin), '/auth/group-login');
assert.equal(sso.groupLoginEntry(config, 'http://127.0.0.1:8766'), config.redirect_uri);
assert.equal(sso.groupLoginEntry(config, 'http://localhost:5666'), config.redirect_uri);
const pending = storage();
await assert.rejects(sso.beginGroupLogin(config, 'http://127.0.0.1:8766', pending, webcrypto, now));
assert.equal(pending.values.size, 0);
assert.throws(() => sso.groupLoginEntry({...config, enabled: false}, origin));
assert.throws(() => sso.groupLoginEntry({...config, redirect_uri: 'https://evil.example/other'}, origin));
assert.throws(() => sso.groupLoginEntry({...config, authorize_url: 'https://evil.example/sso/authorize'}, origin));
assert.throws(() => sso.groupLoginEntry(config, `${origin}/auth/login`));
});
function storage() {
const values = new Map();
return { getItem: key => values.get(key) ?? null, setItem: (key, value) => values.set(key, value),
@@ -152,6 +165,90 @@ test('password login shares authoritative initialization and preserves forced pa
assert.equal(h.events.at(-1), 'route:/profile/password');
});
test('local and ZYT credentials share password API and use only current /me permissions', async () => {
for (const account of [
{ username: 'local_operator', permissions: ['stats:read', 'conversation:read'], home: '/model/stats' },
{ username: 'zyt_new', permissions: [], home: '/group/welcome' },
{ username: 'zyt_authorized', permissions: ['stats:read', 'user:read'], home: '/model/stats' },
]) {
const h = authHarness();
const requests = [];
const requestClient = {
async post(path, body) {
requests.push({ method: 'POST', path, body });
// Even stale/overbroad permissions in the login response are ignored.
return { access_token: `token-${account.username}`, user: { permissions: ['user:write', 'role:write'] } };
},
async get(path) {
requests.push({ method: 'GET', path });
return { id: 12, username: account.username, permissions: account.permissions,
must_change_password: false, role: account.permissions.length ? 'operator' : 'zyt_basic' };
},
};
h.api.loginApi = loadTs('../src/api/core/auth.ts', {
'#/api/request': { requestClient, baseRequestClient: { get() { assert.fail('Password login requested group config'); } } },
}).loginApi;
h.api.getUserInfoApi = loadTs('../src/api/core/user.ts', { '#/api/request': { requestClient } }).getUserInfoApi;
h.api.groupExchangeApi = async () => { assert.fail('Password login used group exchange'); };
await h.store.authLogin({ username: account.username, password: 'Fixture@2026' });
assert.equal(h.access.accessToken, `token-${account.username}`);
assert.deepEqual(h.access.accessCodes, account.permissions);
assert.deepEqual(h.user.userInfo.roles, account.permissions);
assert.equal(h.user.userInfo.username, account.username);
assert.equal(h.access.accessMenus.length, 0);
assert.equal(h.access.accessRoutes.length, 0);
assert.equal(h.access.isAccessChecked, false);
assert.equal(h.persisted.tabs.length, 0);
assert.equal(h.persisted.cachedUser, null);
assert.equal(h.persisted.lock, false);
assert.equal(h.store.loginLoading.value, false);
assert.deepEqual(h.events, ['reset-routes', 'reset-stores', `token:token-${account.username}`, 'route:' + account.home]);
assert.deepEqual(JSON.parse(JSON.stringify(requests)), [
{ method: 'POST', path: '/auth/login', body: { username: account.username, password: 'Fixture@2026', device_name: 'web' } },
{ method: 'GET', path: '/me' },
]);
}
});
test('ordinary password errors release loading and permit retry through the same authLogin', async () => {
const h = authHarness();
let attempts = 0;
h.api.loginApi = async () => {
if (++attempts === 1) throw new Error('账号或密码错误');
return { accessToken: 'retry-basic' };
};
const credentials = { username: 'zyt_new', password: 'Fixture@2026' };
await assert.rejects(h.store.authLogin(credentials), /账号或密码错误/);
assert.equal(h.store.loginLoading.value, false);
assert.deepEqual(h.events, []);
h.setUser({ userId: '8', username: 'zyt_new', roles: [], homePath: '/group/welcome' });
await h.store.authLogin(credentials);
assert.equal(attempts, 2);
assert.equal(h.store.loginLoading.value, false);
assert.equal(h.access.accessToken, 'retry-basic');
assert.deepEqual(h.access.accessCodes, []);
assert.equal(h.events.at(-1), 'route:/group/welcome');
});
test('ordinary password /me failure clears new identity and allows a clean retry', async () => {
const h = authHarness();
h.api.getUserInfoApi = async () => { throw new Error('revoked'); };
const credentials = { username: 'zyt_new', password: 'Fixture@2026' };
await assert.rejects(h.store.authLogin(credentials), /revoked/);
assert.equal(h.store.loginLoading.value, false);
assert.equal(h.access.accessToken, null);
assert.equal(h.access.accessCodes.length, 0);
assert.equal(h.access.accessMenus.length, 0);
assert.equal(h.access.accessRoutes.length, 0);
assert.equal(h.user.userInfo, null);
assert.equal(h.persisted.cachedUser, null);
h.api.getUserInfoApi = async () => ({ userId: '8', username: 'zyt_new', roles: [], homePath: '/group/welcome' });
await h.store.authLogin(credentials);
assert.equal(h.access.accessToken, 'local-user');
assert.equal(h.access.accessCodes.length, 0);
assert.equal(h.events.at(-1), 'route:/group/welcome');
});
test('failure to load current local identity clears the new token and authorization', async () => {
const h = authHarness();
h.api.getUserInfoApi = async () => { throw new Error('revoked'); };
@@ -0,0 +1,140 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vm from 'node:vm';
const require = createRequire(process.env.KEFU_SSO_TYPESCRIPT
? pathToFileURL(resolve(process.env.KEFU_SSO_TYPESCRIPT, 'package.json'))
: new URL('../package.json', import.meta.url));
const ts = require('typescript');
const vue = require('vue');
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc');
const { renderToString } = require('@vue/server-renderer');
const workspace = process.env.KEFU_SSO_WORKSPACE || fileURLToPath(new URL('../../../', import.meta.url));
const translate = { t: value => value };
function load(source, dependencies) {
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
}, reportDiagnostics: true });
assert.deepEqual(compiled.diagnostics, []);
const module = { exports: {} };
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require(name) { assert.ok(name in dependencies, `Unexpected import ${name}`); return dependencies[name]; },
console });
return module.exports;
}
// Compile the actual SFC and its actual permission-tree helpers. Ant components
// only render their props/slots; Vue setup, computed state and template run as-is.
const filename = fileURLToPath(new URL('../src/views/console/roles.vue', import.meta.url));
const { descriptor } = parse(await readFile(filename, 'utf8'), { filename });
const script = compileScript(descriptor, { id: 'local-role-page-test' });
const template = compileTemplate({ source: descriptor.template.content, filename,
id: 'local-role-page-test', compilerOptions: { bindingMetadata: script.bindings } });
assert.deepEqual(template.errors, []);
const helpers = load(await readFile(resolve(workspace, 'apps/web-antd/src/views/console/permission-menu.ts'), 'utf8'), {
'#/locales/t': translate,
});
const plain = tag => vue.defineComponent({ inheritAttrs: false,
setup(_props, { attrs, slots }) { return () => vue.h(tag, attrs, slots.default?.()); } });
const ant = Object.fromEntries(['Alert', 'Card', 'Form', 'FormItem', 'Input', 'Popconfirm', 'Tag']
.map(name => [name, plain('div')]));
ant.Button = plain('button');
ant.Tree = vue.defineComponent({ props: ['disabled'], setup(props) {
return () => vue.h('div', { 'data-permission-tree': String(Boolean(props.disabled)) });
} });
ant.Modal = vue.defineComponent({ props: ['open', 'title', 'okText'], setup(props, { slots }) {
return () => props.open ? vue.h('section', { 'data-modal-title': props.title }, [
vue.h('button', { 'data-modal-action': '' }, props.okText), slots.default?.(),
]) : null;
} });
ant.Table = vue.defineComponent({ props: ['dataSource', 'columns'], setup(props, { slots }) {
return () => vue.h('div', {}, props.dataSource.map(record => vue.h('div', { 'data-role': record.code },
props.columns.map(column => slots.bodyCell?.({ column, record })))));
} });
const catalog = [
{ code: 'user:read', name: '查看用户', group_name: '系统管理', page_name: '用户管理', kind: 'page' },
{ code: 'custom:report', name: '自定义报表', group_name: '报表', page_name: '报表', kind: 'link' },
];
const rows = [
{ code: 'admin', name: '管理员', permissions: ['user:read'], builtin: false, user_count: 0 },
{ code: 'zyt_basic', name: '集团基础用户', permissions: [], builtin: false, user_count: 0 },
{ code: 'reviewer', name: '审核员', permissions: ['user:read', 'custom:report', 'removed:code'], builtin: false, user_count: 0 },
];
async function page(codes, configure = () => {}) {
const calls = { save: [], remove: [], success: [] };
let state;
const component = load(script.content, {
vue, 'vue-router': { useRouter: () => ({ push() {} }) },
'@vben/access': { useAccess: () => ({ hasAccessByCodes: values => values.some(value => codes.includes(value)) }) },
'ant-design-vue': { ...ant, message: { warning() {}, success(value) { calls.success.push(value); } } },
'#/locales/t': translate, './permission-menu': helpers,
'#/api/console': {
async fetchRoles() { return { roles: rows }; }, async fetchPermissions() { return { permissions: catalog }; },
async saveRole(data) { calls.save.push(data); return { roles: rows }; },
async deleteRole(code) { calls.remove.push(code); return { roles: rows }; },
},
}).default;
component.render = load(template.code, { vue }).render;
const original = component.setup;
component.setup = (props, context) => {
state = original(props, context);
state.roles.value = rows;
state.permissions.value = catalog;
configure(state);
return state;
};
const html = await renderToString(vue.createSSRApp(component));
return { state, calls, html };
}
test('管理员和集团基础角色实际组件均为只读:名称/权限禁用、查看与关闭、禁止写删', async () => {
for (const row of rows.slice(0, 2)) {
const { state, calls, html } = await page(['role:write', 'role:delete'], state => state.openEdit(row));
assert.equal(state.readonly.value, true);
assert.match(html, /data-modal-title="查看角色"/);
assert.match(html, /data-modal-action(?:="")?>关闭/);
assert.match(html, new RegExp(`value="${row.name}" disabled`));
assert.match(html, /data-permission-tree="true"/);
assert.match(html, new RegExp(`data-role="${row.code}"[\\s\\S]*?<button[^>]*>查看<\\/button>[\\s\\S]*?<button[^>]*disabled`));
if (row.code === 'zyt_basic') assert.match(html, /请到「系统管理 → 用户管理」为账号分配其他业务角色/);
const before = [...state.form.permissions];
state.onCheck(['user:read']);
assert.deepEqual([...state.form.permissions], before);
await state.submit();
await state.remove(row);
assert.deepEqual(calls.save, []);
assert.deepEqual(calls.remove, []);
}
});
test('普通业务角色仍可编辑并保留自定义权限,保存只过滤失效码并说明在线用户自动同步', async () => {
const { state, calls, html } = await page(['role:read', 'role:write', 'role:delete'], state => state.openEdit(rows[2]));
assert.equal(state.readonly.value, false);
assert.match(html, /data-modal-title="编辑角色"/);
assert.match(html, /data-permission-tree="false"/);
await state.submit();
assert.equal(calls.save.length, 1);
assert.deepEqual([...calls.save[0].permissions], ['user:read', 'custom:report']);
assert.equal(calls.save[0].code, 'reviewer');
assert.match(calls.success[0], /后端权限立即生效/);
assert.match(calls.success[0], /菜单和按钮将在数秒内自动更新/);
await state.remove(rows[2]);
assert.deepEqual(calls.remove, ['reviewer']);
});
test('角色只读用户不能通过组件方法保存或删除业务角色', async () => {
const { state, calls } = await page(['role:read'], state => state.openEdit(rows[2]));
assert.equal(state.readonly.value, true);
await state.submit();
await state.remove(rows[2]);
assert.deepEqual(calls.save, []);
assert.deepEqual(calls.remove, []);
});
@@ -0,0 +1,126 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { resolve } from 'node:path';
import test from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
import vm from 'node:vm';
const require = createRequire(process.env.KEFU_SSO_TYPESCRIPT
? pathToFileURL(resolve(process.env.KEFU_SSO_TYPESCRIPT, 'package.json'))
: new URL('../package.json', import.meta.url));
const ts = require('typescript');
const vue = require('vue');
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc');
const { renderToString } = require('@vue/server-renderer');
function load(source, dependencies) {
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022,
}, reportDiagnostics: true });
assert.deepEqual(compiled.diagnostics, []);
const module = { exports: {} };
vm.runInNewContext(compiled.outputText, { module, exports: module.exports,
require(name) { assert.ok(name in dependencies, `Unexpected import ${name}`); return dependencies[name]; },
// Normal password login must not read browser redirect/storage APIs.
get window() { assert.fail('Password login accessed window'); },
get localStorage() { assert.fail('Password login accessed localStorage'); },
console });
return module.exports;
}
const filename = fileURLToPath(new URL('../src/views/_core/authentication/login.vue', import.meta.url));
const { descriptor } = parse(await readFile(filename, 'utf8'), { filename });
const script = compileScript(descriptor, { id: 'password-login-page-test' });
const template = compileTemplate({ source: descriptor.template.content, filename,
id: 'password-login-page-test', compilerOptions: { bindingMetadata: script.bindings } });
assert.deepEqual(template.errors, []);
async function page(authLogin) {
let submit;
let formProps;
const store = vue.reactive({ loginLoading: false, authLogin });
// Render the real page SFC. The shared form adapter displays its schema/slots
// and exposes the submit listener assigned by the compiled page template.
const AuthenticationLogin = vue.defineComponent({
props: ['formSchema', 'loading', 'title', 'subTitle', 'showCodeLogin', 'showForgetPassword',
'showQrcodeLogin', 'showRegister', 'showRememberMe', 'showThirdPartyLogin'],
emits: ['submit'],
setup(props, { slots }) {
formProps = props;
const instance = vue.getCurrentInstance();
submit = values => instance.vnode.props.onSubmit(values);
return () => vue.h('section', {}, [
vue.h('h1', {}, props.title), vue.h('p', {}, props.subTitle),
vue.h('form', {}, [
...props.formSchema.map(field => vue.h('input', { name: field.fieldName,
type: field.component === 'VbenInputPassword' ? 'password' : 'text',
placeholder: field.componentProps.placeholder })),
vue.h('button', { type: 'submit', disabled: props.loading }, '登录'),
]),
slots['third-party-login']?.(),
]);
},
});
// No config API, group URL helper or router is provided: an accidental import
// or use fails this actual SFC test instead of silently becoming a mock call.
const component = load(script.content, { vue,
'@vben/common-ui': { AuthenticationLogin, z: { string: () => ({ min: () => ({}) }) } },
'#/locales/t': { t: value => value }, '#/store': { useAuthStore: () => store },
}).default;
component.render = load(template.code, { vue }).render;
const html = await renderToString(vue.createSSRApp(component));
return { html, submit, store, formProps };
}
test('实际登录页只有一份账号密码表单和登录按钮,ZYT/本地均走普通提交', async () => {
const calls = [];
const view = await page(async values => { calls.push(values); });
assert.equal((view.html.match(/<form(?:\s|>)/g) || []).length, 1);
assert.equal((view.html.match(/<input\b/g) || []).length, 2);
assert.equal((view.html.match(/<button\b/g) || []).length, 1);
assert.match(view.html, /name="username"[^>]*placeholder="ZYT 账号或客服本地账号"/);
assert.match(view.html, /name="password" type="password"[^>]*placeholder="ZYT 密码或客服本地密码"/);
assert.match(view.html, /ZYT 首次登录为基础用户,业务权限由客服管理员分配/);
assert.doesNotMatch(view.html, /集团账号登录|重试获取集团入口|href=|group-login/);
for (const name of ['showCodeLogin', 'showForgetPassword', 'showQrcodeLogin', 'showRegister',
'showRememberMe', 'showThirdPartyLogin']) assert.equal(view.formProps[name], false);
for (const username of ['local_operator', 'zyt_operator']) {
await view.submit({ username, password: 'Fixture@2026' });
}
assert.deepEqual(calls, [
{ username: 'local_operator', password: 'Fixture@2026' },
{ username: 'zyt_operator', password: 'Fixture@2026' },
]);
});
test('实际登录页普通提交失败后仍可用同一份表单重试', async () => {
let attempts = 0;
const view = await page(async () => {
if (++attempts === 1) throw new Error('账号或密码错误');
return { userInfo: { username: 'zyt_operator' } };
});
const credentials = { username: 'zyt_operator', password: 'Fixture@2026' };
await assert.rejects(view.submit(credentials), /账号或密码错误/);
assert.equal((await view.submit(credentials)).userInfo.username, 'zyt_operator');
assert.equal(attempts, 2);
});
test('ZYT/本地密码只随统一POST body发送,不进入URL或localStorage', async () => {
const requests = [];
const api = load(await readFile(new URL('../src/api/core/auth.ts', import.meta.url), 'utf8'), {
'#/locales/t': { t: value => value },
'#/api/request': {
requestClient: { async post(...args) { requests.push(args); return { access_token: 'current-token' }; } },
baseRequestClient: { get() { assert.fail('Password login fetched group config'); } },
},
});
for (const username of ['local_operator', 'zyt_operator']) {
const result = await api.loginApi({ username, password: 'Fixture@2026', redirect: 'https://unused.example/' });
assert.equal(result.accessToken, 'current-token');
}
assert.deepEqual(JSON.parse(JSON.stringify(requests)), [
['/auth/login', { username: 'local_operator', password: 'Fixture@2026', device_name: 'web' }],
['/auth/login', { username: 'zyt_operator', password: 'Fixture@2026', device_name: 'web' }],
]);
});
+2 -2
View File
@@ -6,12 +6,12 @@ export default defineConfig(async () => {
vite: {
server: {
proxy: {
// 前端统一请求 /api/*,代理到 admin_api.py 的 /api/v2/*。
// 兼容 /api/* 和标准 /api/v2/*,避免重复添加版本前缀。
// 走代理而不是直连,是为了开发态和生产态同源、没有 CORS 差异——
// 生产环境前端是静态文件,由同一个 Nginx 反代到后端。
'/api': {
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '/api/v2'),
rewrite: (path) => path.startsWith('/api/v2/') ? path : path.replace(/^\/api/, '/api/v2'),
// 端口可用环境变量覆盖:本机同时跑多套后端时(比如拿一次性的库
// 验证前端,不碰生产库)不用改代码。
target: process.env.VITE_API_TARGET || 'http://127.0.0.1:8766',