fix: 修复强制重置密码流程与表单状态
- 修正强制改密跳转及免旧密码的服务端校验 - 统一双端表单错误布局与更新按钮加载状态 - 补充认证服务和强制改密路由测试
This commit is contained in:
@@ -25,5 +25,6 @@
|
||||
"repeatPwd": "Please confirm your password again",
|
||||
"notSamePwd": "The two passwords are inconsistent",
|
||||
"passwordStrongTip": "Password must be at least 8 characters and include uppercase, lowercase, numbers, and special characters",
|
||||
"passwordStrengthInvalid": "Password strength requirements are not met",
|
||||
"forceChangePasswordNavigateTip": "For account security, please change your password before visiting other pages."
|
||||
}
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
"repeatPwd": "请再次输入密码",
|
||||
"notSamePwd": "两次输入的密码不一致",
|
||||
"passwordStrongTip": "密码必须至少 8 位,且包含大写字母、小写字母、数字和特殊字符",
|
||||
"passwordStrengthInvalid": "密码强度不足",
|
||||
"forceChangePasswordNavigateTip": "为了账户安全,请先修改密码后再继续访问其他页面"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createMemoryHistory, createRouter } from 'vue-router';
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
buildForcePasswordRoute,
|
||||
isForcePasswordRoute,
|
||||
isPasswordResetRequired,
|
||||
resolveForcePasswordPath,
|
||||
} from '#/utils/password-reset';
|
||||
|
||||
vi.mock('element-plus', () => ({
|
||||
ElMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('#/locales', () => ({
|
||||
$t: (key: string) => key,
|
||||
}));
|
||||
|
||||
function createTestRouter() {
|
||||
return createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{
|
||||
name: 'FallbackNotFound',
|
||||
path: '/:path(.*)*',
|
||||
component: { template: '<div />' },
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('password reset route', () => {
|
||||
it('can resolve the forced password path before Profile is registered', () => {
|
||||
const router = createTestRouter();
|
||||
const route = router.resolve(buildForcePasswordRoute());
|
||||
|
||||
expect(route.fullPath).toBe('/profile?force=1&tab=password');
|
||||
expect(isForcePasswordRoute(route)).toBe(true);
|
||||
expect(resolveForcePasswordPath(router)).toBe(
|
||||
'/profile?force=1&tab=password',
|
||||
);
|
||||
});
|
||||
|
||||
it('matches Profile after the dynamic route is registered', () => {
|
||||
const router = createTestRouter();
|
||||
const targetPath = router.resolve(buildForcePasswordRoute()).fullPath;
|
||||
|
||||
router.addRoute({
|
||||
name: 'Profile',
|
||||
path: '/profile',
|
||||
component: { template: '<div />' },
|
||||
});
|
||||
|
||||
expect(router.resolve(targetPath).name).toBe('Profile');
|
||||
});
|
||||
|
||||
it('uses the account state as the authoritative password reset signal', () => {
|
||||
expect(isPasswordResetRequired({ passwordResetRequired: true })).toBe(true);
|
||||
expect(isPasswordResetRequired({ passwordResetRequired: false })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPasswordResetRequired()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,12 +5,13 @@ import { ElMessage } from 'element-plus';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
const FORCE_PASSWORD_NOTICE_INTERVAL = 1500;
|
||||
const PROFILE_PATH = '/profile';
|
||||
|
||||
let lastForcePasswordNoticeAt = 0;
|
||||
|
||||
export function buildForcePasswordRoute() {
|
||||
return {
|
||||
name: 'Profile',
|
||||
path: PROFILE_PATH,
|
||||
query: {
|
||||
force: '1',
|
||||
tab: 'password',
|
||||
@@ -19,16 +20,20 @@ export function buildForcePasswordRoute() {
|
||||
}
|
||||
|
||||
export function isForcePasswordRoute(
|
||||
route: Pick<{ name?: string | symbol | null; query?: Record<string, any> }, 'name' | 'query'>,
|
||||
route: Pick<{ path?: string; query?: Record<string, any> }, 'path' | 'query'>,
|
||||
) {
|
||||
return route.name === 'Profile' && route.query?.tab === 'password';
|
||||
return route.path === PROFILE_PATH && route.query?.tab === 'password';
|
||||
}
|
||||
|
||||
export function shouldForcePasswordChange(
|
||||
userInfo?: null | Record<string, any>,
|
||||
forceChangePassword?: boolean,
|
||||
) {
|
||||
return !!forceChangePassword || !!userInfo?.passwordResetRequired;
|
||||
return !!forceChangePassword || isPasswordResetRequired(userInfo);
|
||||
}
|
||||
|
||||
export function isPasswordResetRequired(userInfo?: null | Record<string, any>) {
|
||||
return !!userInfo?.passwordResetRequired;
|
||||
}
|
||||
|
||||
export function resolveForcePasswordPath(router: Router) {
|
||||
|
||||
@@ -1,42 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import type {EasyFlowFormSchema} from '#/adapter/form';
|
||||
import type { EasyFlowFormSchema } from '#/adapter/form';
|
||||
|
||||
import {computed, ref} from 'vue';
|
||||
import {useRoute, useRouter} from 'vue-router';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import {ProfilePasswordSetting, z} from '@easyflow/common-ui';
|
||||
import {preferences} from '@easyflow/preferences';
|
||||
import {useUserStore} from '@easyflow/stores';
|
||||
import { ProfilePasswordSetting, z } from '@easyflow/common-ui';
|
||||
import { preferences } from '@easyflow/preferences';
|
||||
import { useUserStore } from '@easyflow/stores';
|
||||
|
||||
import {ElMessage} from 'element-plus';
|
||||
import { ElMessage } from 'element-plus';
|
||||
|
||||
import {getCredentialKeyApi} from '#/api';
|
||||
import {api} from '#/api/request';
|
||||
import {$t} from '#/locales';
|
||||
import {useAuthStore} from '#/store';
|
||||
import {encryptCredentialPayload} from '#/utils/credential-encryption';
|
||||
import {isStrongPassword} from '#/utils/password-policy';
|
||||
import { getCredentialKeyApi } from '#/api';
|
||||
import { api } from '#/api/request';
|
||||
import { $t } from '#/locales';
|
||||
import { useAuthStore } from '#/store';
|
||||
import { encryptCredentialPayload } from '#/utils/credential-encryption';
|
||||
import { isStrongPassword } from '#/utils/password-policy';
|
||||
import { isPasswordResetRequired } from '#/utils/password-reset';
|
||||
|
||||
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';
|
||||
return isPasswordResetRequired(userStore.userInfo);
|
||||
});
|
||||
|
||||
const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
return [
|
||||
{
|
||||
fieldName: 'password',
|
||||
label: $t('sysAccount.oldPwd'),
|
||||
component: 'EasyFlowInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
|
||||
},
|
||||
},
|
||||
const schema: EasyFlowFormSchema[] = [
|
||||
{
|
||||
fieldName: 'newPassword',
|
||||
label: $t('sysAccount.newPwd'),
|
||||
@@ -51,10 +43,12 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
};
|
||||
},
|
||||
rules: z
|
||||
.string({ required_error: $t('sysAccount.newPwd') + $t('common.isRequired') })
|
||||
.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'),
|
||||
message: $t('sysAccount.passwordStrengthInvalid'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
@@ -79,10 +73,29 @@ const formSchema = computed((): EasyFlowFormSchema[] => {
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (!isForcedPasswordChange.value) {
|
||||
schema.unshift({
|
||||
fieldName: 'password',
|
||||
label: $t('sysAccount.oldPwd'),
|
||||
component: 'EasyFlowInputPassword',
|
||||
componentProps: {
|
||||
placeholder: $t('sysAccount.oldPwd') + $t('common.isRequired'),
|
||||
},
|
||||
rules: z
|
||||
.string({
|
||||
required_error: $t('sysAccount.oldPwd') + $t('common.isRequired'),
|
||||
})
|
||||
.min(1, { message: $t('sysAccount.oldPwd') + $t('common.isRequired') }),
|
||||
});
|
||||
}
|
||||
|
||||
return schema;
|
||||
});
|
||||
|
||||
const updateLoading = ref(false);
|
||||
async function handleSubmit(values: any) {
|
||||
const wasForcedPasswordChange = isForcedPasswordChange.value;
|
||||
updateLoading.value = true;
|
||||
try {
|
||||
const encryptedPayload = await encryptCredentialPayload(
|
||||
@@ -96,7 +109,7 @@ async function handleSubmit(values: any) {
|
||||
if (res.errorCode === 0) {
|
||||
ElMessage.success($t('message.success'));
|
||||
const userInfo = await authStore.fetchUserInfo();
|
||||
if (isForcedPasswordChange.value) {
|
||||
if (wasForcedPasswordChange) {
|
||||
await router.replace(
|
||||
userInfo?.homePath || preferences.app.defaultHomePath || '/',
|
||||
);
|
||||
|
||||
@@ -7,15 +7,20 @@ import { computed, nextTick, onUnmounted, useTemplateRef, watch } from 'vue';
|
||||
|
||||
import { CircleAlert } from '@easyflow-core/icons';
|
||||
import {
|
||||
EasyFlowRenderContent,
|
||||
EasyFlowTooltip,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormMessage,
|
||||
EasyFlowRenderContent,
|
||||
EasyFlowTooltip,
|
||||
} from '@easyflow-core/shadcn-ui';
|
||||
import { cn, isFunction, isObject, isString } from '@easyflow-core/shared/utils';
|
||||
import {
|
||||
cn,
|
||||
isFunction,
|
||||
isObject,
|
||||
isString,
|
||||
} from '@easyflow-core/shared/utils';
|
||||
|
||||
import { toTypedSchema } from '@vee-validate/zod';
|
||||
import { useFieldError, useFormValues } from 'vee-validate';
|
||||
@@ -387,7 +392,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<Transition name="slide-up" v-if="!compact">
|
||||
<FormMessage class="absolute" />
|
||||
<FormMessage />
|
||||
</Transition>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
@@ -10,10 +10,14 @@ import { EasyFlowButton } from '@easyflow-core/shadcn-ui';
|
||||
|
||||
interface Props {
|
||||
formSchema?: EasyFlowFormSchema[];
|
||||
buttonText?: string;
|
||||
buttonLoading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
formSchema: () => [],
|
||||
buttonText: '更新密码',
|
||||
buttonLoading: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -49,8 +53,13 @@ defineExpose({
|
||||
<template>
|
||||
<div>
|
||||
<Form />
|
||||
<EasyFlowButton type="submit" class="mt-4" @click="handleSubmit">
|
||||
更新密码
|
||||
<EasyFlowButton
|
||||
:loading="buttonLoading"
|
||||
type="submit"
|
||||
class="mt-4 flex h-8 w-28 p-0"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ buttonText }}
|
||||
</EasyFlowButton>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user