diff --git a/GROUP_IDENTITY.md b/GROUP_IDENTITY.md index bf8577d3a..64e6a72e1 100644 --- a/GROUP_IDENTITY.md +++ b/GROUP_IDENTITY.md @@ -4,6 +4,20 @@ 已有有效 zyt 后台账号可从集团工作台一键进入客服。首次创建固定“集团基础用户”角色,没有业务权限;客服管理员使用原本地账号在“系统管理 → 用户管理”分配角色,在“角色权限”勾选具体能力。再次集团登录保留管理员分配的角色和本地停用状态,不按同名账号合并,不继承医院 root。密码仍由 zyt 验证,绑定账号不能使用或设置客服本地密码。 +网页登录使用同一个账号密码表单:输入zyt账号密码或客服本地账号密码,点击“登录”直接进入客服,不跳转集团后台。本地凭据验证成功时沿用本地账号和角色;否则通过服务器配置的ZYT API实时验密。无医院菜单权限的有效zyt账号也可使用。 + +直接登录仅需客服API配置`ZYT_GROUP_API_URL`。中央端点`POST /adminapi/group/kefu/login`调用原项目无会话验密`POST /adminapi/identity.session/verify`,不创建或注销zyt网页会话,不依赖集团前端运行。密码仅用于本次验证,不在客服保存为本地密码、放入URL或返回给浏览器;密码仍由zyt管理。 + +同名账号不会合并:有效本地凭据优先;只有zyt凭据匹配时,按提供方和稳定GID创建/查找独立绑定账号,首次为无业务权限的“集团基础用户”。原客服管理员在用户管理分配本地业务角色,再次直接登录保持该角色与本地停用状态。不能在客服重置绑定账号的zyt密码。 + +管理端标准API前缀为`/api/v2`,构建产物应设置`VITE_GLOB_API_URL=/api/v2`,可以由8766服务直接托管;开发代理及本机5666预览同时兼容旧`/api`与标准前缀。如果通过其他地址访问,集团登录会先进入已配置的客服回调地址,再发起登录,避免不同域或端口丢失登录事务。回调仍严格固定,不接受任意跳转。 + +本地管理员在用户列表“来源/原账号”找到集团用户,选择业务角色即可;“集团基础用户”固定无权限,只读不可扩权。后端每请求使用新权限,已登录前端在页面可见时每3秒同步菜单和按钮,切回窗口立即同步。仅有角色读取权限的角色管理员也能进入角色页;分配用户角色仍需用户管理权限,不因登录成功自动授予管理能力。 + +集团管理员可查看这里分配的角色和有效权限、同步动态新增的权限目录,并直接编辑已绑定用户的客服授权。客服API配置`ZYT_GROUP_METADATA_KEY_FILE`,集团配置`IAM_KEFU_METADATA_API_URL`及`IAM_KEFU_METADATA_KEY_FILE`,两个文件内容为同一份32字节随机密钥的64位hex表示。文件须放在非公开目录、限制服务用户读取,勿写入浏览器、URL或仓库。Docker需要额外挂载该文件,配置值是容器内只读路径;只有api服务需要它。该密钥同时用于专用授权管理协议,必须仅提供给受信任的集团服务。 + +元数据接口`POST /api/v2/group/access-snapshot`只接受专用HMAC请求并签名响应,不接受普通客服用户token代替机器认证,也不能登录或改授权。按稳定GID关联,查询不会创建账号或认领同名本地账号。集团用户页“客服授权”展示当前角色、启停和有效权限,打开期间每30秒刷新;集团菜单管理的客服公司目录自动拉取,也可点击“同步客服权限”。自定义权限增改删会反映到集团管理目录,删除采用软停用并保留既有引用,恢复沿用原节点。不会把客服权限并入集团/医院授权,也不会自动给集团角色加权限。 + 客服 API 配置以下公开地址,缺少任一项时关闭集团登录: ```dotenv @@ -12,8 +26,12 @@ ZYT_GROUP_API_URL=https://<集团API域名>/adminapi KEFU_PUBLIC_BASE_URL=https://<客服前端域名> ``` -同时在集团配置 `IAM_KEFU_BASE_URL=<客服前端地址>`、`IAM_KEFU_COMPANY_CODE=<实际客服公司编码>`。回调严格为客服前端根路径 `/auth/group-login`,使用 history 路由。生产 HTTPS;只有回环本地开发可用 HTTP。Docker compose 已透传三项配置,不能把密钥或密码放进这些地址。 +同时在集团配置 `IAM_KEFU_BASE_URL=<已登记客服地址>`、`IAM_KEFU_COMPANY_CODE=<实际客服公司编码>`以启用该客户端。上面的前端/回调配置仅为保留集团工作台旧单点入口所需;客服主登录框直接验密只依赖API地址。旧回调仍严格为`/auth/group-login`。生产HTTPS;只有回环本地开发可用HTTP。Docker compose已透传配置,不能把密钥或密码放进这些地址。 集团菜单目录仅控制集团入口显示,客服业务菜单和按钮由客服本地角色决定。集团基础角色固定为空,需给用户分配其他业务角色,而不是给基础角色扩权。客服 SSO 会话最长 8 小时;本地停用和权限调整每请求生效。当前无跨系统全局注销,集团改密/退出会阻止新的 SSO,但不会声称立即撤销所有已签发客服会话。 +集团管理员也可在集团“用户管理 → 客服授权 → 编辑客服授权”中直接分配客服角色,或勾选仅该用户的权限。独立权限通过专属客服角色保存,不修改共享角色或其他用户;实际授权仍存于客服。保存提交后,既有客服会话下一次请求即按新权限执行。2026-11-04起前台页面每3秒同步菜单、按钮和路由,切回窗口或恢复可见立即同步;当前页面失权后返回账号页,不注销登录,仍有权页面及其草稿保留。前端升级首次加载新版本需要刷新一次,后续授权变化无需整页刷新。未绑定用户须先用ZYT账号登录客服,不能按同名本地账号认领。 + +此管理使用独立的`/api/v2/group/access-options`、`/api/v2/group/access-update`端点,沿用服务密钥但绑定实际端点签名,持久化防重放并校验旧版本。普通客服token不能调用。集团仅允许显式集团管理员,并在每次操作核对来源用户/GID/应用归属;两端审计。原`access-snapshot`仍只读。联调协议见集团`integration/kefu-group-authorization-contract.md`。 + 新增回归:`wechat_rpa/test_group_sso.py` 与 `admin-web/apps/web-antd/tests/group-sso.test.mjs`。后端测试需要在合成配置及隔离临时目录运行,勿初始化或重置真实账号库。集团侧跨项目联合测试见 `C:/zyt/identity-admin/tests/kefu-joint-flow.test.mjs`。 diff --git a/admin-web/apps/web-antd/src/api/core/auth.ts b/admin-web/apps/web-antd/src/api/core/auth.ts index b13538c35..e2f259d8c 100644 --- a/admin-web/apps/web-antd/src/api/core/auth.ts +++ b/admin-web/apps/web-antd/src/api/core/auth.ts @@ -36,6 +36,7 @@ export namespace AuthApi { } } +/** ZYT 与客服本地账号共用密码登录;凭证只放 POST body,由服务端选择验证来源。 */ export async function loginApi(data: AuthApi.LoginParams) { const resp = await requestClient.post( '/auth/login', diff --git a/admin-web/apps/web-antd/src/api/request.ts b/admin-web/apps/web-antd/src/api/request.ts index 245ad9de4..b47b8db21 100644 --- a/admin-web/apps/web-antd/src/api/request.ts +++ b/admin-web/apps/web-antd/src/api/request.ts @@ -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( diff --git a/admin-web/apps/web-antd/src/bootstrap.ts b/admin-web/apps/web-antd/src/bootstrap.ts index 811e1a260..0d48f04ac 100644 --- a/admin-web/apps/web-antd/src/bootstrap.ts +++ b/admin-web/apps/web-antd/src/bootstrap.ts @@ -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'); diff --git a/admin-web/apps/web-antd/src/locales/en/permissions.ts b/admin-web/apps/web-antd/src/locales/en/permissions.ts index cf98bd0c5..817b58f7c 100644 --- a/admin-web/apps/web-antd/src/locales/en/permissions.ts +++ b/admin-web/apps/web-antd/src/locales/en/permissions.ts @@ -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"', diff --git a/admin-web/apps/web-antd/src/locales/en/users.ts b/admin-web/apps/web-antd/src/locales/en/users.ts index 8dbc1e9db..25af61781 100644 --- a/admin-web/apps/web-antd/src/locales/en/users.ts +++ b/admin-web/apps/web-antd/src/locales/en/users.ts @@ -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', diff --git a/admin-web/apps/web-antd/src/router/authorization-sync.ts b/admin-web/apps/web-antd/src/router/authorization-sync.ts new file mode 100644 index 000000000..9d2ac1189 --- /dev/null +++ b/admin-web/apps/web-antd/src/router/authorization-sync.ts @@ -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 void>(); + +function routeNames(input: RouteRecordRaw[]): Set { + 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) { + 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; +} diff --git a/admin-web/apps/web-antd/src/router/routes/modules/console.ts b/admin-web/apps/web-antd/src/router/routes/modules/console.ts index e17d20954..e10b66175 100644 --- a/admin-web/apps/web-antd/src/router/routes/modules/console.ts +++ b/admin-web/apps/web-antd/src/router/routes/modules/console.ts @@ -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; diff --git a/admin-web/apps/web-antd/src/utils/authorization-sync.ts b/admin-web/apps/web-antd/src/utils/authorization-sync.ts new file mode 100644 index 000000000..7cce84390 --- /dev/null +++ b/admin-web/apps/web-antd/src/utils/authorization-sync.ts @@ -0,0 +1,91 @@ +interface AuthorizationInfo { + roles?: string[]; + userId: string; +} + +interface Session { + token: string; + userId: string; +} + +interface SyncOptions { + apply: (info: Info, snapshot?: Snapshot) => Promise | void; + document: Pick; + fetch: () => Promise; + getCodes: () => string[]; + getSession: () => Session | undefined; + interval?: number; + prepare: (info: Info) => Promise; + window: Pick; +} + +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(options: SyncOptions) { + let active = false; + let epoch = 0; + let timer: number | undefined; + let pending: Promise | undefined; + let appliedPermissions = permissionKey(options.getCodes()); + + function refresh(): Promise { + 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 }; +} diff --git a/admin-web/apps/web-antd/src/utils/group-sso.ts b/admin-web/apps/web-antd/src/utils/group-sso.ts index 005927957..154e1f925 100644 --- a/admin-web/apps/web-antd/src/utils/group-sso.ts +++ b/admin-web/apps/web-antd/src/utils/group-sso.ts @@ -42,6 +42,15 @@ export function validateGroupConfig(config: GroupConfig, origin: string): GroupC return config as GroupConfig & Required>; } +/** 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('=', ''); } diff --git a/admin-web/apps/web-antd/src/views/_core/authentication/group-login.vue b/admin-web/apps/web-antd/src/views/_core/authentication/group-login.vue index 185e53dab..5f61eec16 100644 --- a/admin-web/apps/web-antd/src/views/_core/authentication/group-login.vue +++ b/admin-web/apps/web-antd/src/views/_core/authentication/group-login.vue @@ -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 { diff --git a/admin-web/apps/web-antd/src/views/_core/authentication/login.vue b/admin-web/apps/web-antd/src/views/_core/authentication/login.vue index 0a4f406b8..525ef1273 100644 --- a/admin-web/apps/web-antd/src/views/_core/authentication/login.vue +++ b/admin-web/apps/web-antd/src/views/_core/authentication/login.vue @@ -1,14 +1,9 @@