feat(auth): 首登强制改密页面 + 创建用户开关

- 独立改密页 + 路由守卫拦截待改密
- auth store 持久化 mustChangePassword
- 用户创建表单加「首登强制改密」开关(默认勾选)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
liuyun 2026-10-11 21:54:41 +08:00
parent ce45d93ce5
commit 5de298a956
8 changed files with 124 additions and 5 deletions

View File

@ -5,6 +5,7 @@ export interface LoginSuccess {
accessToken: string
refreshToken: string
expiresIn: number
mustChangePassword: boolean
}
export interface LoginNeedTenant {

View File

@ -42,6 +42,7 @@ export interface UserUpsertData {
phone?: string
orgCode?: string | null
status?: UserStatus
mustChangePassword?: boolean
}
/** 用户分页列表。 */

View File

@ -0,0 +1,78 @@
<route lang="yaml">
meta:
layout: 'blank'
freeAccess: true
</route>
<template>
<div class="force-pwd">
<el-card class="force-pwd__card" shadow="never">
<h2 class="force-pwd__title">修改初始密码</h2>
<p class="force-pwd__tip">首次登录请先修改初始密码</p>
<change-password-form ref="formRef" />
<el-button type="primary" class="force-pwd__submit" :loading="loading" @click="onSubmit">
确认修改
</el-button>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/modules/auth'
import { changePassword } from '@/api/auth'
import ChangePasswordForm from '@/components/change-password-form.vue'
interface FormApi {
submit: () => Promise<{ oldPassword: string; newPassword: string }>
}
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormApi>()
const loading = ref(false)
async function onSubmit(): Promise<void> {
let result: { oldPassword: string; newPassword: string } | undefined
try {
result = await formRef.value?.submit()
} catch {
return // 校验失败
}
if (result == null) return
loading.value = true
try {
await changePassword(result.oldPassword, result.newPassword)
ElMessage.success('密码已修改,请重新登录')
auth.reset()
router.push('/login')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
.force-pwd {
@apply flex min-h-screen items-center justify-center;
background: var(--wm-bg-color-page);
}
.force-pwd__card {
@apply w-[420px];
}
.force-pwd__title {
@apply text-center text-lg font-semibold;
}
.force-pwd__tip {
@apply mb-md text-center text-sm text-gray-500;
}
.force-pwd__submit {
@apply mt-md w-full;
}
</style>

View File

@ -98,6 +98,12 @@ const formSchema = computed<JsonSchema>(() => ({
minLength: 6,
showOn: ['create'],
},
mustChangePassword: {
type: 'boolean',
title: '首登强制改密',
default: true,
showOn: ['create'],
},
nickname: { type: 'string', title: '昵称', placeholder: '请输入昵称' },
email: { type: 'string', title: '邮箱', format: 'email', placeholder: '请输入邮箱' },
phone: { type: 'string', title: '手机', placeholder: '请输入手机号' },

View File

@ -10,6 +10,7 @@ import { setupLayouts } from 'virtual:generated-layouts'
import i18n, { loadLanguage } from '@/i18n'
import { getAccessToken } from '@/utils/token'
import { useMenuStore } from '@/stores/modules/menu'
import { useAuthStore } from '@/stores/modules/auth'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@ -66,6 +67,12 @@ router.beforeEach(async (to, from, next) => {
next('/login')
return
}
// 首登强制改密:待改密用户仅放行改密页,其余路由重定向
const auth = useAuthStore()
if (auth.mustChangePassword && to.path !== '/force-change-password') {
next('/force-change-password')
return
}
// 路由级权限:自由路径或 meta.freeAccess 放行,其余须在可访问菜单中
if (FREE_PATHS.has(to.path) || to.meta.freeAccess === true) {
next()

View File

@ -2,11 +2,12 @@ import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { http } from '@/api/http';
import { login as loginApi, type LoginResult } from '@/api/auth';
import { clearTokens, getAccessToken, getRefreshToken, setTokens } from '@/utils/token';
import { clearTokens, getAccessToken, getMustChangePassword, getRefreshToken, setMustChangePassword, setTokens } from '@/utils/token';
export const useAuthStore = defineStore('authStore', () => {
const accessToken = ref<string | null>(getAccessToken());
const refreshToken = ref<string | null>(getRefreshToken());
const mustChangePassword = ref<boolean>(getMustChangePassword());
const isAuthenticated = computed(() => !!accessToken.value);
@ -16,6 +17,8 @@ export const useAuthStore = defineStore('authStore', () => {
setTokens(data.accessToken, data.refreshToken);
accessToken.value = data.accessToken;
refreshToken.value = data.refreshToken;
mustChangePassword.value = data.mustChangePassword;
setMustChangePassword(data.mustChangePassword);
}
return data;
}
@ -29,9 +32,7 @@ export const useAuthStore = defineStore('authStore', () => {
// 忽略登出接口错误
}
}
clearTokens();
accessToken.value = null;
refreshToken.value = null;
reset();
}
/** 本地清空会话状态(不调用登出接口,供 401 强退使用)。 */
@ -39,7 +40,9 @@ export const useAuthStore = defineStore('authStore', () => {
clearTokens();
accessToken.value = null;
refreshToken.value = null;
mustChangePassword.value = false;
setMustChangePassword(false);
}
return { accessToken, refreshToken, isAuthenticated, login, logout, reset };
return { accessToken, refreshToken, isAuthenticated, mustChangePassword, login, logout, reset };
});

View File

@ -1,5 +1,6 @@
const ACCESS_KEY = 'sysmgr_access_token';
const REFRESH_KEY = 'sysmgr_refresh_token';
const MUST_CHANGE_KEY = 'sysmgr_must_change_password';
export function getAccessToken(): string | null {
return localStorage.getItem(ACCESS_KEY);
@ -18,3 +19,12 @@ export function clearTokens(): void {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
}
export function getMustChangePassword(): boolean {
return sessionStorage.getItem(MUST_CHANGE_KEY) === '1';
}
export function setMustChangePassword(v: boolean): void {
if (v) sessionStorage.setItem(MUST_CHANGE_KEY, '1');
else sessionStorage.removeItem(MUST_CHANGE_KEY);
}

13
typed-router.d.ts vendored
View File

@ -51,6 +51,13 @@ declare module 'vue-router/auto-routes' {
Record<never, never>,
| never
>,
'/force-change-password': RouteRecordInfo<
'/force-change-password',
'/force-change-password',
Record<never, never>,
Record<never, never>,
| never
>,
'/forgot-password': RouteRecordInfo<
'/forgot-password',
'/forgot-password',
@ -207,6 +214,12 @@ declare module 'vue-router/auto-routes' {
views:
| never
}
'src/pages/force-change-password.vue': {
routes:
| '/force-change-password'
views:
| never
}
'src/pages/forgot-password.vue': {
routes:
| '/forgot-password'