feat: add auth module with login and access control

This commit is contained in:
2026-05-07 14:34:42 +08:00
parent 2d56bc2889
commit b8ba0ea6a0
15 changed files with 501 additions and 34 deletions

View File

@@ -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() {

View File

@@ -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
View File

@@ -0,0 +1,8 @@
import { apiRequest } from './api.js'
export function login(payload) {
return apiRequest('/auth/login', {
method: 'POST',
body: JSON.stringify(payload)
})
}

View 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'}` }
}

View File

@@ -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')

View File

@@ -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
/>