feat: add auth module with login and access control
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import {
|
||||
loginBootstrapAdmin,
|
||||
saveBootstrapConfig,
|
||||
testBootstrapDatabase,
|
||||
testBootstrapRuntime
|
||||
} from '../services/bootstrap.js'
|
||||
import { login as loginByAccount } from '../services/auth.js'
|
||||
import { resolveDefaultAuthorizedRoute } from '../utils/accessControl.js'
|
||||
import { useToast } from './useToast.js'
|
||||
|
||||
const AUTH_STORAGE_KEY = 'x-financial-authenticated'
|
||||
const AUTH_USERNAME_KEY = 'x-financial-auth-username'
|
||||
const AUTH_USER_KEY = 'x-financial-auth-user'
|
||||
const AUTH_LAST_ACTIVITY_KEY = 'x-financial-auth-last-activity'
|
||||
const DEFAULT_USER_NAME = '系统管理员'
|
||||
const DEFAULT_USER_ROLE = '财务管理员'
|
||||
const DEFAULT_USER_ROLE = '管理员'
|
||||
const SESSION_ACTIVITY_EVENTS = ['pointerdown', 'keydown', 'scroll', 'touchstart', 'visibilitychange']
|
||||
const authIdleTimeoutMinutes = Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30)
|
||||
const authIdleTimeoutMs =
|
||||
@@ -74,6 +76,67 @@ function readStoredUsername() {
|
||||
return window.sessionStorage.getItem(AUTH_USERNAME_KEY) || ''
|
||||
}
|
||||
|
||||
function buildAnonymousUser() {
|
||||
return {
|
||||
username: '',
|
||||
name: '',
|
||||
role: '',
|
||||
roleCodes: [],
|
||||
email: '',
|
||||
avatar: '',
|
||||
isAdmin: false
|
||||
}
|
||||
}
|
||||
|
||||
function buildLegacyAdminUser(username = '') {
|
||||
const normalized = String(username || '').trim()
|
||||
const name = normalized || DEFAULT_USER_NAME
|
||||
|
||||
return {
|
||||
username: normalized,
|
||||
name,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
roleCodes: ['manager'],
|
||||
email: '',
|
||||
avatar: name.slice(0, 1).toUpperCase(),
|
||||
isAdmin: true
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredUser() {
|
||||
if (typeof window === 'undefined') {
|
||||
return buildAnonymousUser()
|
||||
}
|
||||
|
||||
const raw = window.sessionStorage.getItem(AUTH_USER_KEY)
|
||||
|
||||
if (raw) {
|
||||
try {
|
||||
const payload = JSON.parse(raw)
|
||||
if (payload && typeof payload === 'object') {
|
||||
const username = String(payload.username || '').trim()
|
||||
const name = String(payload.name || username || DEFAULT_USER_NAME).trim()
|
||||
const roleCodes = Array.isArray(payload.roleCodes) ? payload.roleCodes.filter(Boolean) : []
|
||||
|
||||
return {
|
||||
username,
|
||||
name,
|
||||
role: String(payload.role || DEFAULT_USER_ROLE),
|
||||
roleCodes,
|
||||
email: String(payload.email || ''),
|
||||
avatar: String(payload.avatar || name.slice(0, 1).toUpperCase()),
|
||||
isAdmin: Boolean(payload.isAdmin)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return buildLegacyAdminUser(readStoredUsername())
|
||||
}
|
||||
}
|
||||
|
||||
const legacyUsername = readStoredUsername()
|
||||
return legacyUsername ? buildLegacyAdminUser(legacyUsername) : buildAnonymousUser()
|
||||
}
|
||||
|
||||
function readLastActivityAt() {
|
||||
if (typeof window === 'undefined') {
|
||||
return 0
|
||||
@@ -82,17 +145,6 @@ function readLastActivityAt() {
|
||||
return Number(window.sessionStorage.getItem(AUTH_LAST_ACTIVITY_KEY) || 0)
|
||||
}
|
||||
|
||||
function buildCurrentUser(username = '') {
|
||||
const normalized = String(username || '').trim()
|
||||
const name = normalized || DEFAULT_USER_NAME
|
||||
|
||||
return {
|
||||
name,
|
||||
role: DEFAULT_USER_ROLE,
|
||||
avatar: name.slice(0, 1).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
function isSessionExpired(now = Date.now()) {
|
||||
if (!readAuthState()) {
|
||||
return false
|
||||
@@ -107,19 +159,22 @@ function isSessionExpired(now = Date.now()) {
|
||||
return now - lastActivityAt > authIdleTimeoutMs
|
||||
}
|
||||
|
||||
function persistAuthState(value, username = '') {
|
||||
function persistAuthState(value, user = null) {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
if (value) {
|
||||
window.sessionStorage.setItem(AUTH_STORAGE_KEY, 'true')
|
||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(username || '').trim())
|
||||
const normalizedUser = user || buildAnonymousUser()
|
||||
window.sessionStorage.setItem(AUTH_USERNAME_KEY, String(normalizedUser.username || '').trim())
|
||||
window.sessionStorage.setItem(AUTH_USER_KEY, JSON.stringify(normalizedUser))
|
||||
return
|
||||
}
|
||||
|
||||
window.sessionStorage.removeItem(AUTH_STORAGE_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USERNAME_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_USER_KEY)
|
||||
window.sessionStorage.removeItem(AUTH_LAST_ACTIVITY_KEY)
|
||||
}
|
||||
|
||||
@@ -213,7 +268,7 @@ function syncAuthSession(options = {}) {
|
||||
|
||||
if (!readAuthState()) {
|
||||
loggedIn.value = false
|
||||
currentUser.value = buildCurrentUser('')
|
||||
currentUser.value = buildAnonymousUser()
|
||||
clearSessionTimeout()
|
||||
return false
|
||||
}
|
||||
@@ -224,7 +279,7 @@ function syncAuthSession(options = {}) {
|
||||
}
|
||||
|
||||
loggedIn.value = true
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
currentUser.value = readStoredUser()
|
||||
scheduleSessionTimeout()
|
||||
return true
|
||||
}
|
||||
@@ -250,7 +305,7 @@ const databaseTestMessage = ref('')
|
||||
const loginSubmitting = ref(false)
|
||||
const loginError = ref('')
|
||||
const loggedIn = ref(readAuthState() && !isSessionExpired())
|
||||
const currentUser = ref(buildCurrentUser(readStoredUsername()))
|
||||
const currentUser = ref(readStoredUser())
|
||||
|
||||
if (!loggedIn.value && readAuthState()) {
|
||||
persistAuthState(false)
|
||||
@@ -288,7 +343,7 @@ function resetFromClientEnv() {
|
||||
applyBootstrapState(readClientBootstrapState())
|
||||
clearSetupRuntimeState()
|
||||
loginError.value = ''
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
currentUser.value = readStoredUser()
|
||||
}
|
||||
|
||||
async function handleSetupSubmit(payload) {
|
||||
@@ -382,19 +437,20 @@ async function handleLogin(credentials) {
|
||||
loginError.value = ''
|
||||
|
||||
try {
|
||||
await loginBootstrapAdmin({
|
||||
const response = await loginByAccount({
|
||||
username: credentials.username,
|
||||
password: credentials.password
|
||||
})
|
||||
|
||||
const user = response?.user || buildAnonymousUser()
|
||||
loggedIn.value = true
|
||||
persistAuthState(true, credentials.username)
|
||||
currentUser.value = buildCurrentUser(credentials.username)
|
||||
persistAuthState(true, user)
|
||||
currentUser.value = user
|
||||
touchAuthActivity(true)
|
||||
return true
|
||||
} catch (error) {
|
||||
logout('invalid', { redirect: false })
|
||||
loginError.value = error.message || '登录失败,请检查管理员账号和密码。'
|
||||
loginError.value = error.message || '登录失败,请检查账号和密码。'
|
||||
toast(loginError.value)
|
||||
return false
|
||||
} finally {
|
||||
@@ -408,7 +464,7 @@ function logout(reason = 'manual', options = {}) {
|
||||
|
||||
loggedIn.value = false
|
||||
persistAuthState(false)
|
||||
currentUser.value = buildCurrentUser('')
|
||||
currentUser.value = buildAnonymousUser()
|
||||
clearSessionTimeout()
|
||||
|
||||
if (notify) {
|
||||
@@ -421,7 +477,7 @@ function logout(reason = 'manual', options = {}) {
|
||||
}
|
||||
|
||||
function handleRecoverPassword() {
|
||||
toast('请联系系统管理员重置密码。管理员密码不会写入 .env。')
|
||||
toast('请联系系统管理员重置账号密码。')
|
||||
}
|
||||
|
||||
function handleSsoLogin() {
|
||||
@@ -430,7 +486,7 @@ function handleSsoLogin() {
|
||||
|
||||
function resolveEntryRoute() {
|
||||
loggedIn.value = syncAuthSession()
|
||||
currentUser.value = buildCurrentUser(readStoredUsername())
|
||||
currentUser.value = readStoredUser()
|
||||
|
||||
if (!isInitialized.value) {
|
||||
return { name: 'setup' }
|
||||
@@ -440,7 +496,7 @@ function resolveEntryRoute() {
|
||||
return { name: 'login' }
|
||||
}
|
||||
|
||||
return { name: 'app-overview' }
|
||||
return resolveDefaultAuthorizedRoute(currentUser.value)
|
||||
}
|
||||
|
||||
export function useSystemState() {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { checkBackendHealth } from '../composables/useBackendHealth.js'
|
||||
import { appViews } from '../composables/useNavigation.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { canAccessAppView } from '../utils/accessControl.js'
|
||||
import AppShellRouteView from '../views/AppShellRouteView.vue'
|
||||
import BackendUnavailableRouteView from '../views/BackendUnavailableRouteView.vue'
|
||||
import LoginRouteView from '../views/LoginRouteView.vue'
|
||||
@@ -80,7 +81,7 @@ const router = createRouter({
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
const { isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
|
||||
const { currentUser, isInitialized, loggedIn, resolveEntryRoute, syncAuthSession } = useSystemState()
|
||||
const authActive = syncAuthSession({ notify: Boolean(to.meta.requiresAuth) })
|
||||
|
||||
if (!isInitialized.value) {
|
||||
@@ -105,6 +106,10 @@ router.beforeEach((to) => {
|
||||
return resolveEntryRoute()
|
||||
}
|
||||
|
||||
if (ok && typeof to.meta.appView === 'string' && !canAccessAppView(currentUser.value, to.meta.appView)) {
|
||||
return resolveEntryRoute()
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
8
web/src/services/auth.js
Normal file
8
web/src/services/auth.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export function login(payload) {
|
||||
return apiRequest('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
62
web/src/utils/accessControl.js
Normal file
62
web/src/utils/accessControl.js
Normal file
@@ -0,0 +1,62 @@
|
||||
export const DEFAULT_APP_VIEW_ORDER = [
|
||||
'overview',
|
||||
'workbench',
|
||||
'requests',
|
||||
'approval',
|
||||
'chat',
|
||||
'policies',
|
||||
'audit',
|
||||
'employees'
|
||||
]
|
||||
|
||||
const ALWAYS_VISIBLE_VIEWS = new Set(['workbench', 'requests', 'chat'])
|
||||
const VIEW_ROLE_RULES = {
|
||||
overview: ['finance', 'executive'],
|
||||
approval: ['approver'],
|
||||
policies: ['manager'],
|
||||
audit: ['auditor'],
|
||||
employees: ['manager']
|
||||
}
|
||||
|
||||
function normalizedRoleCodes(user) {
|
||||
if (!user) {
|
||||
return []
|
||||
}
|
||||
|
||||
return Array.isArray(user.roleCodes) ? user.roleCodes.filter(Boolean) : []
|
||||
}
|
||||
|
||||
export function isManagerUser(user) {
|
||||
return Boolean(user?.isAdmin) || normalizedRoleCodes(user).includes('manager')
|
||||
}
|
||||
|
||||
export function canAccessAppView(user, viewId) {
|
||||
if (!viewId || !user) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isManagerUser(user)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (ALWAYS_VISIBLE_VIEWS.has(viewId)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const requiredRoles = VIEW_ROLE_RULES[viewId] || []
|
||||
const roleCodes = normalizedRoleCodes(user)
|
||||
return requiredRoles.some((roleCode) => roleCodes.includes(roleCode))
|
||||
}
|
||||
|
||||
export function getAccessibleViewIds(user) {
|
||||
return DEFAULT_APP_VIEW_ORDER.filter((viewId) => canAccessAppView(user, viewId))
|
||||
}
|
||||
|
||||
export function filterNavItemsByAccess(navItems, user) {
|
||||
return navItems.filter((item) => canAccessAppView(user, item.id))
|
||||
}
|
||||
|
||||
export function resolveDefaultAuthorizedRoute(user) {
|
||||
const firstVisibleView = getAccessibleViewIds(user)[0]
|
||||
return { name: `app-${firstVisibleView || 'workbench'}` }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="app">
|
||||
<SidebarRail
|
||||
:nav-items="navItems"
|
||||
:nav-items="filteredNavItems"
|
||||
:active-view="activeView"
|
||||
:current-user="currentUser"
|
||||
@navigate="handleNavigate"
|
||||
@@ -124,7 +124,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import SidebarRail from '../components/layout/SidebarRail.vue'
|
||||
import TopBar from '../components/layout/TopBar.vue'
|
||||
@@ -142,6 +142,7 @@ import EmployeeManagementView from './EmployeeManagementView.vue'
|
||||
|
||||
import { useAppShell } from '../composables/useAppShell.js'
|
||||
import { useSystemState } from '../composables/useSystemState.js'
|
||||
import { filterNavItemsByAccess } from '../utils/accessControl.js'
|
||||
|
||||
const employeeSummary = ref(null)
|
||||
|
||||
@@ -183,6 +184,7 @@ const {
|
||||
} = useAppShell()
|
||||
|
||||
const { currentUser, logout } = useSystemState()
|
||||
const filteredNavItems = computed(() => filterNavItemsByAccess(navItems, currentUser.value))
|
||||
|
||||
function handleLogout() {
|
||||
logout('manual')
|
||||
|
||||
@@ -71,14 +71,14 @@
|
||||
|
||||
<header class="card-head">
|
||||
<h2>欢迎登录</h2>
|
||||
<p>使用初始化时创建的管理员账号进入系统</p>
|
||||
<p>使用员工邮箱或管理员账号进入系统</p>
|
||||
</header>
|
||||
|
||||
<form class="login-form" @submit.prevent="emit('login', { username, password })">
|
||||
<label class="field">
|
||||
<span class="sr-only">账号</span>
|
||||
<i class="mdi mdi-account-outline"></i>
|
||||
<input v-model="username" type="text" placeholder="请输入管理员账号" autocomplete="username" required />
|
||||
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
@@ -87,7 +87,7 @@
|
||||
<input
|
||||
v-model="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入管理员密码"
|
||||
placeholder="请输入登录密码"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user