feat(@vben/web-antd): 顶栏项目切换器 + 物联运维平台 SSO 无感跳转
- 顶栏 TenantDropdown 替换为 ProjectDropdown(新建 widget)
- 进入时拉 /system/project/simple-list;仅当本地 projectId 不在列表时
才回退到首项,避免静默改写用户选择
- 空列表不渲染,避免出现永远空下拉
- 新增"物联运维"按钮,走 OAuth2 authorization code 流程跳 IoT 前端
- state 使用 crypto.randomUUID() / getRandomValues() 生成(CSRF 防护)
- VITE_IOT_BASE_URL 未配置时按钮隐藏,不再硬编码兜底 URL
- 使用原生 <button disabled> 替代 <a role="button">,修复可访问性
- 新增 /sso-callback 回调页 + /system/sso/callback API
- 挂载后立即 history.replaceState 清 code/state,避免二次 exchange
- API 层做 snake_case → camelCase 映射,统一前端风格
- 文档化 redirectUri 必须与 OAuth2 客户端 redirectUris 白名单一致
- authStore 新增 ssoLogin,与 authLogin 抽取共用 postAuthSuccess
- token 为空直接抛错,fetchUserInfo 失败回滚 token 避免 401 循环
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
52
apps/web-antd/src/api/core/sso.ts
Normal file
52
apps/web-antd/src/api/core/sso.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { requestClient } from '#/api/request';
|
||||
|
||||
/**
|
||||
* SSO 单点登录相关接口(对接 /system/sso/callback)
|
||||
* 用于从其他子系统(如 IoT 运维平台)跳转回来时,用 code 换 access_token
|
||||
*/
|
||||
export namespace SsoApi {
|
||||
/** 后端 OAuth2 标准响应(snake_case),仅在 API 层内部使用 */
|
||||
export interface SsoCallbackRawResult {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
}
|
||||
/** 前端统一使用 camelCase */
|
||||
export interface SsoCallbackResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
tokenType: string;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用 SSO code 换 access_token。
|
||||
* client_secret 不在前端,由后端从配置读取。
|
||||
*
|
||||
* @param clientId 当前系统的 client_id(业务平台 = 'biz',物联运维 = 'iot')
|
||||
* @param code 授权码(从 URL 读取)
|
||||
* @param redirectUri 回调地址(必须与发起授权时一致,且需在 OAuth2 客户端的 redirectUris 白名单内)
|
||||
* @param state 发起授权时的随机串,从 URL 读出原样回传(后端校验一致性 + CSRF 防护)
|
||||
*/
|
||||
export async function ssoCallback(
|
||||
clientId: string,
|
||||
code: string,
|
||||
redirectUri: string,
|
||||
state?: string,
|
||||
): Promise<SsoApi.SsoCallbackResult> {
|
||||
const raw = await requestClient.post<SsoApi.SsoCallbackRawResult>(
|
||||
'/system/sso/callback',
|
||||
null,
|
||||
{
|
||||
params: { clientId, code, redirectUri, state },
|
||||
},
|
||||
);
|
||||
return {
|
||||
accessToken: raw.access_token,
|
||||
refreshToken: raw.refresh_token,
|
||||
expiresIn: raw.expires_in,
|
||||
tokenType: raw.token_type,
|
||||
};
|
||||
}
|
||||
@@ -1,20 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import type { NotificationItem } from '@vben/layouts';
|
||||
|
||||
import type { SystemTenantApi } from '#/api/system/tenant';
|
||||
import type { SystemProjectApi } from '#/api/system/project';
|
||||
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
|
||||
import { useAccess } from '@vben/access';
|
||||
import { AuthenticationLoginExpiredModal, useVbenModal } from '@vben/common-ui';
|
||||
import { isTenantEnable, useTabs, useWatermark } from '@vben/hooks';
|
||||
import { useTabs, useWatermark } from '@vben/hooks';
|
||||
import { AntdProfileOutlined, CircleHelp } from '@vben/icons';
|
||||
import {
|
||||
BasicLayout,
|
||||
Help,
|
||||
LockScreen,
|
||||
Notification,
|
||||
TenantDropdown,
|
||||
ProjectDropdown,
|
||||
UserDropdown,
|
||||
} from '@vben/layouts';
|
||||
import { preferences } from '@vben/preferences';
|
||||
@@ -29,7 +28,8 @@ import {
|
||||
updateAllNotifyMessageRead,
|
||||
updateNotifyMessageRead,
|
||||
} from '#/api/system/notify/message';
|
||||
import { getSimpleTenantList } from '#/api/system/tenant';
|
||||
import { authorize } from '#/api/system/oauth2/open';
|
||||
import { getSimpleProjectList } from '#/api/system/project';
|
||||
import { $t } from '#/locales';
|
||||
import { router } from '#/router';
|
||||
import { useAuthStore } from '#/store';
|
||||
@@ -38,7 +38,6 @@ import LoginForm from '#/views/_core/authentication/login.vue';
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const accessStore = useAccessStore();
|
||||
const { hasAccessByCodes } = useAccess();
|
||||
const { destroyWatermark, updateWatermark } = useWatermark();
|
||||
const { closeOtherTabs, refreshTab } = useTabs();
|
||||
|
||||
@@ -149,33 +148,83 @@ function handleNotificationOpen(open: boolean) {
|
||||
handleNotificationGetUnreadCount();
|
||||
}
|
||||
|
||||
// 租户列表
|
||||
const tenants = ref<SystemTenantApi.Tenant[]>([]);
|
||||
const tenantEnable = computed(
|
||||
() => hasAccessByCodes(['system:tenant:visit']) && isTenantEnable(),
|
||||
);
|
||||
// 项目列表
|
||||
const projects = ref<SystemProjectApi.Project[]>([]);
|
||||
|
||||
/** 获取租户列表 */
|
||||
async function handleGetTenantList() {
|
||||
if (tenantEnable.value) {
|
||||
tenants.value = await getSimpleTenantList();
|
||||
/** 获取项目列表 */
|
||||
async function handleGetProjectList() {
|
||||
projects.value = await getSimpleProjectList();
|
||||
// 仅在当前 projectId 不在返回列表中(未选 / 已删除 / 无权限)时,回退到第一个
|
||||
const currentId = accessStore.projectId;
|
||||
const currentInList =
|
||||
currentId !== null && projects.value.some((p) => p.id === currentId);
|
||||
if (!currentInList) {
|
||||
const firstId = projects.value[0]?.id ?? null;
|
||||
accessStore.setProjectId(firstId as null | number);
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理租户切换 */
|
||||
async function handleTenantChange(tenant: SystemTenantApi.Tenant) {
|
||||
if (!tenant || !tenant.id) {
|
||||
message.error('切换租户失败');
|
||||
/** 跳转到物联运维平台(OAuth2 授权码无感跳转) */
|
||||
const iotLoading = ref(false);
|
||||
// env 未配置时按钮直接不渲染,避免把用户带到无效域名
|
||||
const iotBaseUrl = import.meta.env.VITE_IOT_BASE_URL as string | undefined;
|
||||
/** 生成加密安全的 state 值用于 CSRF 防护 */
|
||||
function generateState(): string {
|
||||
if (
|
||||
typeof crypto !== 'undefined' &&
|
||||
typeof crypto.randomUUID === 'function'
|
||||
) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// 老浏览器兜底:仍使用 getRandomValues,强于 Math.random
|
||||
const buf = new Uint8Array(16);
|
||||
crypto.getRandomValues(buf);
|
||||
return Array.from(buf, (b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
async function handleGoToIot() {
|
||||
if (iotLoading.value || !iotBaseUrl) return;
|
||||
iotLoading.value = true;
|
||||
try {
|
||||
// 注意:redirectUri 必须与 OAuth2 客户端 redirectUris 白名单完全一致,否则后端拒发 code
|
||||
const redirectUri = `${iotBaseUrl}/sso-callback`;
|
||||
const state = generateState();
|
||||
const redirectUrl = await authorize(
|
||||
'code',
|
||||
'iot', // 物联运维平台的 OAuth2 客户端编号
|
||||
redirectUri,
|
||||
state,
|
||||
true, // auto_approve 跳过授权确认页
|
||||
['user.read', 'user.write'],
|
||||
[],
|
||||
);
|
||||
if (!redirectUrl) {
|
||||
message.error('获取授权失败,请重试');
|
||||
return;
|
||||
}
|
||||
window.location.href = redirectUrl;
|
||||
} catch (error: any) {
|
||||
message.error(error?.message ?? '跳转物联运维平台失败');
|
||||
} finally {
|
||||
iotLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理项目切换 */
|
||||
async function handleProjectChange(project: SystemProjectApi.Project) {
|
||||
if (!project || !project.id) {
|
||||
message.error('切换项目失败');
|
||||
return;
|
||||
}
|
||||
// 设置访问租户 ID
|
||||
accessStore.setVisitTenantId(tenant.id as number);
|
||||
if (project.id === accessStore.projectId) {
|
||||
return;
|
||||
}
|
||||
// 设置当前项目 ID(axios 拦截器会自动带 project-id 请求头)
|
||||
accessStore.setProjectId(project.id as number);
|
||||
// 关闭其他标签页,只保留当前页
|
||||
await closeOtherTabs();
|
||||
// 刷新当前页面
|
||||
// 刷新当前页面,重新拉数据
|
||||
await refreshTab();
|
||||
// 提示切换成功
|
||||
message.success(`切换当前租户为: ${tenant.name}`);
|
||||
message.success(`切换当前项目为: ${project.name}`);
|
||||
}
|
||||
|
||||
// ========== 初始化 ==========
|
||||
@@ -184,8 +233,8 @@ let notifyTimer: null | ReturnType<typeof setInterval> = null;
|
||||
onMounted(() => {
|
||||
// 首次加载未读数量
|
||||
handleNotificationGetUnreadCount();
|
||||
// 获取租户列表
|
||||
handleGetTenantList();
|
||||
// 获取项目列表
|
||||
handleGetProjectList();
|
||||
// 轮询刷新未读数量
|
||||
notifyTimer = setInterval(
|
||||
() => {
|
||||
@@ -250,14 +299,27 @@ watch(
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-1>
|
||||
<div v-if="tenantEnable">
|
||||
<TenantDropdown
|
||||
class="mr-2"
|
||||
:tenant-list="tenants"
|
||||
:visit-tenant-id="accessStore.visitTenantId"
|
||||
@success="handleTenantChange"
|
||||
/>
|
||||
</div>
|
||||
<ProjectDropdown
|
||||
v-if="projects.length > 0"
|
||||
class="mr-2"
|
||||
:project-list="projects"
|
||||
:visit-project-id="accessStore.projectId"
|
||||
@success="handleProjectChange"
|
||||
/>
|
||||
</template>
|
||||
<template #header-right-2>
|
||||
<button
|
||||
v-if="iotBaseUrl"
|
||||
type="button"
|
||||
class="ml-1 mr-2 flex h-8 cursor-pointer items-center gap-1.5 rounded-full bg-[#1677FF] px-4 text-sm font-medium text-white shadow-sm transition-all hover:bg-[#4096FF] hover:shadow-md disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="iotLoading"
|
||||
title="进入物联运维平台"
|
||||
@click="handleGoToIot"
|
||||
>
|
||||
<span class="i-lucide:cpu text-base"></span>
|
||||
<span>物联运维</span>
|
||||
<span class="i-lucide:arrow-up-right text-xs opacity-80"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template #extra>
|
||||
<AuthenticationLoginExpiredModal
|
||||
|
||||
@@ -109,6 +109,18 @@ const coreRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SsoCallback',
|
||||
path: '/sso-callback',
|
||||
component: () => import('#/views/_core/authentication/sso-callback.vue'),
|
||||
meta: {
|
||||
hideInBreadcrumb: true,
|
||||
hideInMenu: true,
|
||||
hideInTab: true,
|
||||
ignoreAccess: true,
|
||||
title: 'SSO 登录中',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export { coreRoutes, fallbackNotFoundRoute };
|
||||
|
||||
@@ -29,6 +29,42 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
/**
|
||||
* token 写入成功后的共用流程:拉用户信息、跳首页、弹欢迎提示。
|
||||
* authLogin / ssoLogin 共用以避免逻辑漂移。
|
||||
*/
|
||||
async function postAuthSuccess(
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
): Promise<null | UserInfo> {
|
||||
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
|
||||
if (accessStore.tenantId && !accessStore.visitTenantId) {
|
||||
accessStore.setVisitTenantId(accessStore.tenantId);
|
||||
}
|
||||
|
||||
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
|
||||
const fetchUserInfoResult = await fetchUserInfo();
|
||||
const userInfo = fetchUserInfoResult.user;
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
return userInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
@@ -41,7 +77,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
let loginResult: AuthApi.LoginResult;
|
||||
@@ -64,52 +99,48 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
const { accessToken, refreshToken } = loginResult;
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
|
||||
// 登录成功后,如果未设置访问租户,默认使用登录时选择的租户
|
||||
if (accessStore.tenantId && !accessStore.visitTenantId) {
|
||||
accessStore.setVisitTenantId(accessStore.tenantId);
|
||||
}
|
||||
|
||||
// 获取用户信息并存储到 userStore、accessStore 中
|
||||
// TODO @芋艿:清理掉 accessCodes 相关的逻辑
|
||||
// const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
// fetchUserInfo(),
|
||||
// // getAccessCodesApi(),
|
||||
// ]);
|
||||
const fetchUserInfoResult = await fetchUserInfo();
|
||||
|
||||
userInfo = fetchUserInfoResult.user;
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
onSuccess
|
||||
? await onSuccess?.()
|
||||
: await router.push(
|
||||
userInfo.homePath || preferences.app.defaultHomePath,
|
||||
);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
notification.success({
|
||||
description: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
duration: 3,
|
||||
message: $t('authentication.loginSuccess'),
|
||||
});
|
||||
}
|
||||
if (!accessToken) {
|
||||
return { userInfo: null };
|
||||
}
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
userInfo = await postAuthSuccess(onSuccess);
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
return { userInfo };
|
||||
}
|
||||
|
||||
/**
|
||||
* SSO 登录:从其他平台跳转过来时,已经拿到 access_token,跳过密码校验。
|
||||
* 失败(token 空、后续拉用户信息异常)会回滚 token,避免留下空 token 导致刷新循环。
|
||||
*/
|
||||
async function ssoLogin(
|
||||
accessToken: string,
|
||||
refreshToken: string,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
if (!accessToken || !refreshToken) {
|
||||
throw new Error('SSO 登录失败:缺少 accessToken / refreshToken');
|
||||
}
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
accessStore.setAccessToken(accessToken);
|
||||
accessStore.setRefreshToken(refreshToken);
|
||||
try {
|
||||
userInfo = await postAuthSuccess(onSuccess);
|
||||
} catch (error) {
|
||||
// 回滚 token,避免 Bearer null/空 token 陷入 401 循环
|
||||
accessStore.setAccessToken(null);
|
||||
accessStore.setRefreshToken(null);
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
return { userInfo };
|
||||
}
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
@@ -155,6 +186,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
return {
|
||||
$reset,
|
||||
authLogin,
|
||||
ssoLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
logout,
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { LOGIN_PATH } from '@vben/constants';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { ssoCallback } from '#/api/core/sso';
|
||||
import { CLIENT_ID } from '#/api/request';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
/**
|
||||
* SSO 回调页
|
||||
*
|
||||
* 场景:从其他子系统(如 IoT 运维平台)跳转回来时,URL 会带上 code 参数:
|
||||
* biz.xxx.com/sso-callback?code=xxx&state=yyy
|
||||
*
|
||||
* 约束:window.location.origin + '/sso-callback' 必须与发起授权时传的 redirect_uri 完全一致,
|
||||
* 且已登记在后端 OAuth2 客户端的 redirectUris 白名单中,否则 yudao 会拒发 / 换 token。
|
||||
*/
|
||||
defineOptions({ name: 'SsoCallback' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const errorMsg = ref<string>('');
|
||||
|
||||
onMounted(async () => {
|
||||
const code = route.query.code as string | undefined;
|
||||
// state 必须从 URL 原样回传,否则后端会抛 OAUTH2_GRANT_STATE_MISMATCH
|
||||
const state = (route.query.state as string) || undefined;
|
||||
// 立即清空 URL 查询参数,避免 code 出现在浏览器历史、后退时二次 exchange
|
||||
window.history.replaceState(null, '', window.location.pathname);
|
||||
|
||||
if (!code) {
|
||||
errorMsg.value = 'SSO 登录失败:缺少授权码';
|
||||
message.error(errorMsg.value);
|
||||
await router.replace(LOGIN_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const redirectUri = `${window.location.origin}/sso-callback`;
|
||||
const tokenResp = await ssoCallback(CLIENT_ID, code, redirectUri, state);
|
||||
// 用拿到的 token 完成登录(会拉用户信息、菜单并跳首页)
|
||||
await authStore.ssoLogin(tokenResp.accessToken, tokenResp.refreshToken);
|
||||
} catch (error: any) {
|
||||
errorMsg.value = error?.message ?? 'SSO 登录失败,请重新登录';
|
||||
message.error(errorMsg.value);
|
||||
await router.replace(LOGIN_PATH);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-screen items-center justify-center text-base">
|
||||
<span v-if="errorMsg">{{ errorMsg }}</span>
|
||||
<span v-else>正在登录,请稍候...</span>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user