i更新
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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' }],
|
||||
]);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user