feat: 支持账号导入与强制改密
- 新增账号导入模板下载、导入校验和默认密码重置标记 - 支持管理员重置密码并在登录后强制跳转修改密码 - 管理端与用户中心接入强密码校验和密码重置流程
This commit is contained in:
@@ -10,6 +10,7 @@ export namespace AuthApi {
|
||||
/** 登录接口返回值 */
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
forceChangePassword?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -23,5 +23,7 @@
|
||||
"newPwd": "新密码",
|
||||
"confirmPwd": "确认密码",
|
||||
"repeatPwd": "请再次输入密码",
|
||||
"notSamePwd": "两次输入的密码不一致"
|
||||
"notSamePwd": "两次输入的密码不一致",
|
||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
|
||||
}
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
// 生成菜单和路由
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal file
6
easyflow-ui-usercenter/app/src/utils/password-policy.ts
Normal 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);
|
||||
}
|
||||
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal file
52
easyflow-ui-usercenter/app/src/utils/password-reset.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user