feat: 增加开发模式 URL 免登录
- 新增 dev-only 且仅限本机访问的 admin 免登入口 - 管理端支持通过 ?devLogin=admin 自动换取登录态并清理 URL 参数 - 删除未受保护的临时 token 接口并补充关键单测
This commit is contained in:
@@ -3,10 +3,15 @@ import { baseRequestClient, requestClient } from '#/api/request';
|
||||
export namespace AuthApi {
|
||||
/** 登录接口参数 */
|
||||
export interface LoginParams {
|
||||
account?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
}
|
||||
|
||||
export interface DevLoginParams {
|
||||
account: string;
|
||||
}
|
||||
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
@@ -26,6 +31,16 @@ export async function loginApi(data: AuthApi.LoginParams) {
|
||||
return requestClient.post<AuthApi.LoginResult>('/api/v1/auth/login', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发模式免登录
|
||||
*/
|
||||
export async function devLoginApi(data: AuthApi.DevLoginParams) {
|
||||
return requestClient.post<AuthApi.LoginResult>(
|
||||
'/api/v1/auth/dev-login',
|
||||
data,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新accessToken
|
||||
*/
|
||||
|
||||
57
easyflow-ui-admin/app/src/router/__tests__/dev-login.test.ts
Normal file
57
easyflow-ui-admin/app/src/router/__tests__/dev-login.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
getDevLoginAccount,
|
||||
removeDevLoginQuery,
|
||||
shouldAttemptDevLogin,
|
||||
} from '../dev-login';
|
||||
|
||||
describe('dev-login route helpers', () => {
|
||||
it('reads the admin account from the query string', () => {
|
||||
expect(getDevLoginAccount({ devLogin: 'admin' })).toBe('admin');
|
||||
expect(getDevLoginAccount({ devLogin: ['admin', 'other'] })).toBe('admin');
|
||||
expect(getDevLoginAccount({ devLogin: ' ' })).toBeNull();
|
||||
});
|
||||
|
||||
it('removes only the devLogin query parameter', () => {
|
||||
expect(
|
||||
removeDevLoginQuery({
|
||||
devLogin: 'admin',
|
||||
redirect: '/ai/workflow',
|
||||
}),
|
||||
).toEqual({
|
||||
redirect: '/ai/workflow',
|
||||
});
|
||||
});
|
||||
|
||||
it('attempts dev login only in dev mode and without an existing token', () => {
|
||||
expect(
|
||||
shouldAttemptDevLogin({
|
||||
account: 'admin',
|
||||
hasAccessToken: false,
|
||||
isDev: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
shouldAttemptDevLogin({
|
||||
account: 'admin',
|
||||
hasAccessToken: true,
|
||||
isDev: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAttemptDevLogin({
|
||||
account: 'guest',
|
||||
hasAccessToken: false,
|
||||
isDev: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
shouldAttemptDevLogin({
|
||||
account: 'admin',
|
||||
hasAccessToken: false,
|
||||
isDev: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
39
easyflow-ui-admin/app/src/router/dev-login.ts
Normal file
39
easyflow-ui-admin/app/src/router/dev-login.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { LocationQuery, LocationQueryRaw } from 'vue-router';
|
||||
|
||||
const DEV_LOGIN_ACCOUNT = 'admin';
|
||||
const DEV_LOGIN_QUERY_KEY = 'devLogin';
|
||||
|
||||
function normalizeQueryValue(
|
||||
value: LocationQuery[string] | LocationQueryRaw[string],
|
||||
) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0] ?? null;
|
||||
}
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
export function getDevLoginAccount(query: LocationQuery | LocationQueryRaw) {
|
||||
const value = normalizeQueryValue(query[DEV_LOGIN_QUERY_KEY]);
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
const account = value.trim();
|
||||
return account.length > 0 ? account : null;
|
||||
}
|
||||
|
||||
export function removeDevLoginQuery(query: LocationQuery | LocationQueryRaw) {
|
||||
const { [DEV_LOGIN_QUERY_KEY]: _ignored, ...nextQuery } = query;
|
||||
return nextQuery;
|
||||
}
|
||||
|
||||
export function shouldAttemptDevLogin(params: {
|
||||
account: null | string;
|
||||
hasAccessToken: boolean;
|
||||
isDev: boolean;
|
||||
}) {
|
||||
return (
|
||||
params.isDev &&
|
||||
!params.hasAccessToken &&
|
||||
params.account === DEV_LOGIN_ACCOUNT
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,19 @@
|
||||
import type {Router} from 'vue-router';
|
||||
import type { Router } from 'vue-router';
|
||||
|
||||
import {LOGIN_PATH} from '@easyflow/constants';
|
||||
import {preferences} from '@easyflow/preferences';
|
||||
import {useAccessStore, useUserStore} from '@easyflow/stores';
|
||||
import {startProgress, stopProgress} from '@easyflow/utils';
|
||||
import { LOGIN_PATH } from '@easyflow/constants';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useAccessStore, useUserStore } from '@easyflow/stores';
|
||||
import { startProgress, stopProgress } from '@easyflow/utils';
|
||||
|
||||
import {accessRoutes, coreRouteNames} from '#/router/routes';
|
||||
import {useAuthStore} from '#/store';
|
||||
import { accessRoutes, coreRouteNames } from '#/router/routes';
|
||||
import { useAuthStore } from '#/store';
|
||||
|
||||
import {generateAccess} from './access';
|
||||
import { generateAccess } from './access';
|
||||
import {
|
||||
getDevLoginAccount,
|
||||
removeDevLoginQuery,
|
||||
shouldAttemptDevLogin,
|
||||
} from './dev-login';
|
||||
|
||||
interface NetworkConnectionLike {
|
||||
effectiveType?: string;
|
||||
@@ -121,10 +126,59 @@ function setupCommonGuard(router: Router) {
|
||||
* @param router
|
||||
*/
|
||||
function setupAccessGuard(router: Router) {
|
||||
let devLoginPromise: null | Promise<void> = null;
|
||||
|
||||
router.beforeEach(async (to, from) => {
|
||||
const accessStore = useAccessStore();
|
||||
const userStore = useUserStore();
|
||||
const authStore = useAuthStore();
|
||||
const devLoginAccount = getDevLoginAccount(to.query);
|
||||
|
||||
const resolveCleanFullPath = () =>
|
||||
router.resolve({
|
||||
hash: to.hash,
|
||||
path: to.path,
|
||||
query: removeDevLoginQuery(to.query),
|
||||
}).fullPath;
|
||||
|
||||
if (devLoginAccount && import.meta.env.DEV && accessStore.accessToken) {
|
||||
return resolveCleanFullPath();
|
||||
}
|
||||
|
||||
if (
|
||||
shouldAttemptDevLogin({
|
||||
account: devLoginAccount,
|
||||
hasAccessToken: !!accessStore.accessToken,
|
||||
isDev: import.meta.env.DEV,
|
||||
})
|
||||
) {
|
||||
const account = devLoginAccount;
|
||||
try {
|
||||
devLoginPromise ??= authStore
|
||||
.authDevLogin(account ?? '')
|
||||
.then(() => undefined)
|
||||
.finally(() => {
|
||||
devLoginPromise = null;
|
||||
});
|
||||
await devLoginPromise;
|
||||
const cleanFullPath = resolveCleanFullPath();
|
||||
if (window.location.search.includes('devLogin=')) {
|
||||
window.location.replace(cleanFullPath);
|
||||
return false;
|
||||
}
|
||||
return cleanFullPath;
|
||||
} catch {
|
||||
const cleanFullPath = resolveCleanFullPath();
|
||||
return {
|
||||
path: LOGIN_PATH,
|
||||
query:
|
||||
cleanFullPath === preferences.app.defaultHomePath
|
||||
? {}
|
||||
: { redirect: encodeURIComponent(cleanFullPath) },
|
||||
replace: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 基本路由,这些路由不需要进入权限拦截
|
||||
if (coreRouteNames.includes(to.name as string)) {
|
||||
@@ -147,13 +201,17 @@ function setupAccessGuard(router: Router) {
|
||||
|
||||
// 没有访问权限,跳转登录页面
|
||||
if (to.fullPath !== LOGIN_PATH) {
|
||||
const cleanFullPath =
|
||||
devLoginAccount && import.meta.env.DEV
|
||||
? resolveCleanFullPath()
|
||||
: to.fullPath;
|
||||
return {
|
||||
path: LOGIN_PATH,
|
||||
// 如不需要,直接删除 query
|
||||
query:
|
||||
to.fullPath === preferences.app.defaultHomePath
|
||||
cleanFullPath === preferences.app.defaultHomePath
|
||||
? {}
|
||||
: { redirect: encodeURIComponent(to.fullPath) },
|
||||
: { redirect: encodeURIComponent(cleanFullPath) },
|
||||
// 携带当前跳转的页面,登录后重新跳转该页面
|
||||
replace: true,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,13 @@ import { resetAllStores, useAccessStore, useUserStore } from '@easyflow/stores';
|
||||
import { ElNotification } from 'element-plus';
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
|
||||
import {
|
||||
devLoginApi,
|
||||
getAccessCodesApi,
|
||||
getUserInfoApi,
|
||||
loginApi,
|
||||
logoutApi,
|
||||
} from '#/api';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -20,6 +26,50 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
const loginLoading = ref(false);
|
||||
|
||||
async function finalizeLogin(
|
||||
accessToken: string,
|
||||
options: {
|
||||
notify?: boolean;
|
||||
onSuccess?: () => Promise<void> | void;
|
||||
skipRedirect?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
let userInfo: null | UserInfo = null;
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else if (!options.skipRedirect) {
|
||||
const homePath =
|
||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||
options.onSuccess
|
||||
? await options.onSuccess()
|
||||
: await router.push(homePath);
|
||||
}
|
||||
|
||||
if (options.notify !== false && userInfo?.nickname) {
|
||||
ElNotification({
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo.nickname}`,
|
||||
title: $t('authentication.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 异步处理登录操作
|
||||
* Asynchronously handle the login process
|
||||
@@ -29,53 +79,35 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
params: Recordable<any>,
|
||||
onSuccess?: () => Promise<void> | void,
|
||||
) {
|
||||
// 异步处理用户登录操作并获取 accessToken
|
||||
let userInfo: null | UserInfo = null;
|
||||
try {
|
||||
loginLoading.value = true;
|
||||
const { token: accessToken } = await loginApi(params);
|
||||
|
||||
// 如果成功获取到 accessToken
|
||||
if (accessToken) {
|
||||
// 将 accessToken 存储到 accessStore 中
|
||||
accessStore.setAccessToken(accessToken);
|
||||
|
||||
// 获取用户信息并存储到 accessStore 中
|
||||
const [fetchUserInfoResult, accessCodes] = await Promise.all([
|
||||
fetchUserInfo(),
|
||||
getAccessCodesApi(),
|
||||
]);
|
||||
|
||||
userInfo = fetchUserInfoResult;
|
||||
|
||||
userStore.setUserInfo(userInfo);
|
||||
accessStore.setAccessCodes(accessCodes);
|
||||
|
||||
if (accessStore.loginExpired) {
|
||||
accessStore.setLoginExpired(false);
|
||||
} else {
|
||||
const homePath =
|
||||
userInfo.homePath || preferences.app.defaultHomePath || '/';
|
||||
onSuccess ? await onSuccess?.() : await router.push(homePath);
|
||||
}
|
||||
|
||||
if (userInfo?.nickname) {
|
||||
ElNotification({
|
||||
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
|
||||
title: $t('authentication.loginSuccess'),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
return await finalizeLogin(accessToken, { onSuccess });
|
||||
}
|
||||
} finally {
|
||||
loginLoading.value = false;
|
||||
}
|
||||
|
||||
return {
|
||||
userInfo,
|
||||
userInfo: null,
|
||||
};
|
||||
}
|
||||
|
||||
async function authDevLogin(account: string) {
|
||||
const { token: accessToken } = await devLoginApi({ account });
|
||||
if (!accessToken) {
|
||||
return {
|
||||
userInfo: null,
|
||||
};
|
||||
}
|
||||
return finalizeLogin(accessToken, {
|
||||
notify: false,
|
||||
skipRedirect: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function logout(redirect: boolean = true) {
|
||||
try {
|
||||
await logoutApi();
|
||||
@@ -109,6 +141,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
return {
|
||||
$reset,
|
||||
authDevLogin,
|
||||
authLogin,
|
||||
fetchUserInfo,
|
||||
loginLoading,
|
||||
|
||||
Reference in New Issue
Block a user