Merge origin/ai-11-2 into order-9-22

Integrate group password sign-in and live authorization synchronization while
retaining user editing and the restored knowledge review workspace.

Keep local database snapshots and dependency cache trees from the target branch.
Complete English translations and resolve test dependencies through their
owning workspace packages so the new tests work with pnpm's strict layout.
This commit is contained in:
gr
2026-11-04 10:27:26 +08:00
27 changed files with 2588 additions and 63 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');
@@ -6,6 +6,9 @@
* 管理员自己加的条目(custom)没有译文,原样显示。
*/
export default {
'已保存,后端权限立即生效;在线用户的菜单和按钮将在数秒内自动更新。': 'Saved. Server permissions take effect immediately; menus and buttons update automatically within a few seconds for online users.',
'后端权限修改后立即生效,无需重启或重新登录;在线用户的菜单和按钮将在数秒内自动更新,切回窗口时立即同步。管理员和集团基础用户角色只读,内置角色和仍有人在用的角色不可删除。要新增权限点或左侧菜单链接,去「系统管理 → 权限菜单」。': 'Server permission changes take effect immediately without restarting or signing in again. Menus and buttons update within a few seconds for online users and sync immediately when the window regains focus. Administrator and group basic roles are read-only. Built-in roles and roles assigned to users cannot be deleted. Add permissions or navigation links under System → Permission menus.',
'集团基础用户固定无业务权限,不能修改。请到「系统管理 → 用户管理」为账号分配其他业务角色。': 'The group basic role has no business permissions and cannot be changed. Assign another business role to the account under System → Users.',
// ── 权限菜单页 permissions.vue ──────────────────────────────────────────────
'已新增 {code}。管理员自动拥有它;其他角色去「角色权限」里勾选':
'Added {code}. Administrators get it automatically; grant it to other roles under "Roles"',
@@ -1,5 +1,9 @@
/** views/console/users.vue */
export default {
'ZYT 账号或客服本地账号': 'ZYT account or local service account',
'请输入账号': 'Enter your account',
'ZYT 密码或客服本地密码': 'ZYT password or local service password',
'ZYT 首次登录为基础用户,业务权限由客服管理员分配。': 'First-time ZYT users start with basic access. Service administrators assign business permissions.',
'登录响应无效,请重新登录': 'Invalid login response. Please sign in again',
'集团登录地址配置无效': 'Invalid group sign-in URL configuration',
'集团登录尚未启用': 'Group sign-in is not enabled',
@@ -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,328 @@
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: createRequire(require.resolve('@vben/request'))('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,141 @@
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 vueRequire = createRequire(require.resolve('vue/package.json'));
const { parse, compileScript, compileTemplate } = vueRequire('@vue/compiler-sfc');
const { renderToString } = vueRequire('@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,127 @@
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 vueRequire = createRequire(require.resolve('vue/package.json'));
const { parse, compileScript, compileTemplate } = vueRequire('@vue/compiler-sfc');
const { renderToString } = vueRequire('@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',