feat: 支持账号导入与强制改密

- 新增账号导入模板下载、导入校验和默认密码重置标记

- 支持管理员重置密码并在登录后强制跳转修改密码

- 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
2026-03-18 21:56:05 +08:00
parent 14c78d54f5
commit 5d3c7d8692
40 changed files with 1720 additions and 142 deletions

View File

@@ -10,6 +10,7 @@ export namespace AuthApi {
/** 登录接口返回值 */
export interface LoginResult {
accessToken: string;
forceChangePassword?: boolean;
token: string;
}

View File

@@ -6,5 +6,5 @@ import { requestClient } from '#/api/request';
* 获取用户信息
*/
export async function getUserInfoApi() {
return requestClient.get<UserInfo>('/api/v1/sysAccount/myProfile');
return requestClient.get<UserInfo>('/userCenter/sysAccount/myProfile');
}

View File

@@ -23,5 +23,7 @@
"newPwd": "NewPassword",
"confirmPwd": "ConfirmPassword",
"repeatPwd": "Please confirm your password again",
"notSamePwd": "The two passwords are inconsistent"
"notSamePwd": "The two passwords are inconsistent",
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages."
}

View File

@@ -23,5 +23,7 @@
"newPwd": "新密码",
"confirmPwd": "确认密码",
"repeatPwd": "请再次输入密码",
"notSamePwd": "两次输入的密码不一致"
"notSamePwd": "两次输入的密码不一致",
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
}

View File

@@ -7,6 +7,12 @@ import { startProgress, stopProgress } from '@easyflow/utils';
import { accessRoutes, coreRouteNames } from '#/router/routes';
import { useAuthStore } from '#/store';
import {
buildForcePasswordRoute,
isForcePasswordRoute,
notifyForcePasswordChange,
shouldForcePasswordChange,
} from '#/utils/password-reset';
import { generateAccess } from './access';
@@ -53,6 +59,10 @@ function setupAccessGuard(router: Router) {
// 基本路由,这些路由不需要进入权限拦截
if (coreRouteNames.includes(to.name as string)) {
if (to.path === LOGIN_PATH && accessStore.accessToken) {
const currentUser = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(currentUser)) {
return buildForcePasswordRoute();
}
return decodeURIComponent(
(to.query?.redirect as string) ||
userStore.userInfo?.homePath ||
@@ -85,6 +95,14 @@ function setupAccessGuard(router: Router) {
return to;
}
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
if (shouldForcePasswordChange(userInfo) && !isForcePasswordRoute(to)) {
if (from.name) {
notifyForcePasswordChange();
}
return buildForcePasswordRoute();
}
// 是否已经生成过动态路由
if (accessStore.isAccessChecked) {
return true;
@@ -92,7 +110,6 @@ function setupAccessGuard(router: Router) {
// 生成路由表
// 当前登录用户拥有的角色标识列表
const userInfo = userStore.userInfo || (await authStore.fetchUserInfo());
const userRoles = userInfo.roles ?? [];
// 生成菜单和路由

View File

@@ -12,6 +12,10 @@ import { defineStore } from 'pinia';
import { getAccessCodesApi, getUserInfoApi, loginApi, logoutApi } from '#/api';
import { $t } from '#/locales';
import {
buildForcePasswordRoute,
shouldForcePasswordChange,
} from '#/utils/password-reset';
export const useAuthStore = defineStore('auth', () => {
const accessStore = useAccessStore();
@@ -20,6 +24,55 @@ export const useAuthStore = defineStore('auth', () => {
const loginLoading = ref(false);
async function finalizeLogin(
accessToken: string,
forceChangePassword?: boolean,
onSuccess?: () => Promise<void> | void,
) {
let userInfo: null | UserInfo = null;
accessStore.setAccessToken(accessToken);
const [fetchUserInfoResult, accessCodes] = await Promise.all([
fetchUserInfo(),
getAccessCodesApi(),
]);
userInfo = fetchUserInfoResult;
userStore.setUserInfo(userInfo);
accessStore.setAccessCodes(accessCodes);
const forcePasswordChange = shouldForcePasswordChange(
userInfo,
forceChangePassword,
);
if (accessStore.loginExpired) {
accessStore.setLoginExpired(false);
}
if (forcePasswordChange) {
await router.push(buildForcePasswordRoute());
} else {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.nickname) {
ElNotification({
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
title: $t('authentication.loginSuccess'),
type: 'success',
});
}
return {
userInfo,
};
}
/**
* 异步处理登录操作
* Asynchronously handle the login process
@@ -29,52 +82,19 @@ 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);
const { forceChangePassword, 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 {
onSuccess
? await onSuccess?.()
: await router.push(
userInfo.homePath || preferences.app.defaultHomePath,
);
}
if (userInfo?.nickname) {
ElNotification({
message: `${$t('authentication.loginSuccessDesc')}:${userInfo?.nickname}`,
title: $t('authentication.loginSuccess'),
type: 'success',
});
}
return await finalizeLogin(accessToken, forceChangePassword, onSuccess);
}
} finally {
loginLoading.value = false;
}
return {
userInfo,
userInfo: null,
};
}

View File

@@ -0,0 +1,6 @@
export const strongPasswordPattern =
/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,}$/;
export function isStrongPassword(value?: string) {
return !!value && strongPasswordPattern.test(value);
}

View File

@@ -0,0 +1,52 @@
import type { Router } from 'vue-router';
import { ElMessage } from 'element-plus';
import { $t } from '#/locales';
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
let lastForcePasswordNoticeAt = 0;
export function buildForcePasswordRoute() {
return {
name: 'Profile',
query: {
force: '1',
tab: 'password',
},
};
}
export function isForcePasswordRoute(
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
) {
return route.name === 'Profile' && route.query?.tab === 'password';
}
export function shouldForcePasswordChange(
userInfo?: null | Record<string, any>,
forceChangePassword?: boolean,
) {
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
}
export function resolveForcePasswordPath(router: Router) {
return router.resolve(buildForcePasswordRoute()).fullPath;
}
export function notifyForcePasswordChange() {
const now = Date.now();
if (now - lastForcePasswordNoticeAt < FORCE_PASSWORD_NOTICE_INTERVAL) {
return;
}
lastForcePasswordNoticeAt = now;
ElMessage({
message: $t('sysAccount.forceChangePasswordNavigateTip'),
type: 'warning',
duration: 2200,
grouping: true,
plain: true,
showClose: true,
});
}

View File

@@ -1,36 +1,66 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { Profile } from '@easyflow/common-ui';
import { useUserStore } from '@easyflow/stores';
import { $t } from '#/locales';
import ProfileBase from './base-setting.vue';
import ProfileNotificationSetting from './notification-setting.vue';
import ProfilePasswordSetting from './password-setting.vue';
import ProfileSecuritySetting from './security-setting.vue';
const route = useRoute();
const userStore = useUserStore();
const tabsValue = ref<string>('basic');
const tabs = ref([
{
label: '基本设置',
value: 'basic',
const forcePasswordChange = computed(() => {
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
});
const tabs = computed(() => {
if (forcePasswordChange.value) {
return [
{
label: $t('settingsConfig.updatePwd'),
value: 'password',
},
];
}
return [
{
label: '基本设置',
value: 'basic',
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
];
});
watch(
() => [route.query.force, route.query.tab, userStore.userInfo?.passwordResetRequired],
() => {
if (forcePasswordChange.value) {
tabsValue.value = 'password';
return;
}
tabsValue.value = (route.query.tab as string) || 'basic';
},
{
label: '安全设置',
value: 'security',
},
{
label: '修改密码',
value: 'password',
},
{
label: '新消息提醒',
value: 'notice',
},
]);
{ immediate: true },
);
</script>
<template>
<Profile

View File

@@ -2,48 +2,75 @@
import type { EasyFlowFormSchema } from '#/adapter/form';
import { computed, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
import { preferences } from '@easyflow/preferences';
import { useUserStore } from '@easyflow/stores';
import { ElMessage } from 'element-plus';
import { api } from '#/api/request';
import { $t } from '#/locales';
import { useAuthStore } from '#/store';
import { isStrongPassword } from '#/utils/password-policy';
const profilePasswordSettingRef = ref();
const authStore = useAuthStore();
const route = useRoute();
const router = useRouter();
const userStore = useUserStore();
const isForcedPasswordChange = computed(() => {
return !!userStore.userInfo?.passwordResetRequired || route.query.force === '1';
});
const formSchema = computed((): EasyFlowFormSchema[] => {
return [
{
fieldName: 'oldPassword',
label: '旧密码',
fieldName: 'password',
label: $t('sysAccount.oldPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
placeholder: '请输入旧密码',
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
},
},
{
fieldName: 'newPassword',
label: '新密码',
label: $t('sysAccount.newPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请输入新密码',
placeholder: $t('sysAccount.newPwd') + $t('common.isRequired'),
},
renderComponentContent() {
return {
strengthText: () => $t('sysAccount.passwordStrongTip'),
};
},
rules: z
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
.min(1, { message: $t('sysAccount.newPwd') + $t('common.isRequired') })
.refine((value) => isStrongPassword(value), {
message: $t('sysAccount.passwordStrongTip'),
}),
},
{
fieldName: 'confirmPassword',
label: '确认密码',
label: $t('sysAccount.confirmPwd'),
component: 'EasyFlowInputPassword',
componentProps: {
passwordStrength: true,
placeholder: '请再次输入新密码',
placeholder: $t('sysAccount.repeatPwd'),
},
dependencies: {
rules(values) {
const { newPassword } = values;
return z
.string({ required_error: '请再次输入新密码' })
.min(1, { message: '请再次输入新密码' })
.string({ required_error: $t('sysAccount.repeatPwd') })
.min(1, { message: $t('sysAccount.repeatPwd') })
.refine((value) => value === newPassword, {
message: '两次输入的密码不一致',
message: $t('sysAccount.notSamePwd'),
});
},
triggerFields: ['newPassword'],
@@ -52,12 +79,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
];
});
function handleSubmit() {
ElMessage.success('密码修改成功');
const updateLoading = ref(false);
async function handleSubmit(values: any) {
updateLoading.value = true;
try {
const res = await api.post('/userCenter/sysAccount/updatePassword', values);
if (res.errorCode === 0) {
ElMessage.success($t('message.success'));
const userInfo = await authStore.fetchUserInfo();
if (isForcedPasswordChange.value) {
await router.replace(
userInfo?.homePath || preferences.app.defaultHomePath || '/',
);
}
}
} finally {
updateLoading.value = false;
}
}
</script>
<template>
<ProfilePasswordSetting
:button-loading="updateLoading"
:button-text="$t('button.update')"
ref="profilePasswordSettingRef"
class="w-1/3"
:form-schema="formSchema"