192 lines
5.1 KiB
TypeScript
192 lines
5.1 KiB
TypeScript
import { defineStore } from 'pinia'
|
|
import { ref, computed } from 'vue'
|
|
import request, { setTokens, clearTokens, onTokenRefreshed, onAuthExpired, setRememberMe, getRememberMe } from '@/utils/request'
|
|
import { setAdminSession, clearAdminSession, ADMIN_ROLES } from '@/utils/permission'
|
|
|
|
export interface AdminUser {
|
|
id: number
|
|
username: string
|
|
display_name: string
|
|
role: string
|
|
}
|
|
|
|
export interface PendingAgreement {
|
|
id: number
|
|
code: string
|
|
title: string
|
|
version: string
|
|
}
|
|
|
|
interface LoginResponse {
|
|
access_token: string
|
|
refresh_token: string
|
|
expires_at: number
|
|
user: AdminUser
|
|
permissions: string[]
|
|
pending_agreements?: PendingAgreement[]
|
|
}
|
|
|
|
const STORE_KEY = 'admin_auth'
|
|
|
|
export const useAuthStore = defineStore('admin-auth', () => {
|
|
const token = ref<string | null>(null)
|
|
const refreshToken = ref<string | null>(null)
|
|
const expiresAt = ref<number | null>(null)
|
|
const user = ref<AdminUser | null>(null)
|
|
const permissions = ref<string[]>([])
|
|
const pendingAgreements = ref<PendingAgreement[]>([])
|
|
const rememberMe = ref<boolean>(getRememberMe())
|
|
|
|
const isLoggedIn = computed(() => !!token.value)
|
|
|
|
const isSuperAdmin = computed(() => user.value?.role === ADMIN_ROLES.SUPER_ADMIN)
|
|
|
|
function persist(): void {
|
|
const data = {
|
|
token: token.value,
|
|
refreshToken: refreshToken.value,
|
|
expiresAt: expiresAt.value,
|
|
user: user.value,
|
|
permissions: permissions.value,
|
|
}
|
|
localStorage.setItem(STORE_KEY, JSON.stringify(data))
|
|
}
|
|
|
|
function hydrate(): void {
|
|
try {
|
|
const raw = localStorage.getItem(STORE_KEY)
|
|
if (!raw) return
|
|
const data = JSON.parse(raw) as {
|
|
token: string | null
|
|
refreshToken: string | null
|
|
expiresAt: number | null
|
|
user: AdminUser | null
|
|
permissions: string[]
|
|
}
|
|
token.value = data.token
|
|
refreshToken.value = data.refreshToken
|
|
expiresAt.value = data.expiresAt
|
|
user.value = data.user
|
|
permissions.value = data.permissions ?? []
|
|
} catch {
|
|
// corrupted data — ignore
|
|
}
|
|
}
|
|
|
|
function applySession(loginRes: LoginResponse): void {
|
|
token.value = loginRes.access_token
|
|
refreshToken.value = loginRes.refresh_token
|
|
expiresAt.value = loginRes.expires_at
|
|
user.value = loginRes.user
|
|
permissions.value = loginRes.permissions ?? []
|
|
|
|
setTokens({
|
|
accessToken: loginRes.access_token,
|
|
refreshToken: loginRes.refresh_token,
|
|
expiresAt: loginRes.expires_at,
|
|
})
|
|
setAdminSession(loginRes.user.role, loginRes.permissions ?? [])
|
|
persist()
|
|
}
|
|
|
|
async function login(username: string, password: string, captcha?: { captchaKey: string; captchaX: number }, remember: boolean = true): Promise<any> {
|
|
// Save user's "remember me" preference
|
|
rememberMe.value = remember
|
|
setRememberMe(remember)
|
|
|
|
const { data } = await request.post('/admin/auth/login', {
|
|
username,
|
|
password,
|
|
captcha_key: captcha?.captchaKey,
|
|
captcha_x: captcha?.captchaX,
|
|
})
|
|
if (data.data?.totp_required) {
|
|
return data.data
|
|
}
|
|
pendingAgreements.value = data.data?.pending_agreements || []
|
|
applySession(data.data)
|
|
return data.data
|
|
}
|
|
|
|
function applyTokensFrom2FA(loginData: any): void {
|
|
pendingAgreements.value = loginData?.pending_agreements || []
|
|
applySession(loginData)
|
|
}
|
|
|
|
/** 同步清空全部登录数据(响应式状态 + token storage + session 持久化),不调用后端 */
|
|
function clearAuthState(): void {
|
|
token.value = null
|
|
refreshToken.value = null
|
|
expiresAt.value = null
|
|
user.value = null
|
|
permissions.value = []
|
|
pendingAgreements.value = []
|
|
|
|
clearTokens()
|
|
clearAdminSession()
|
|
localStorage.removeItem(STORE_KEY)
|
|
}
|
|
|
|
/** 同步本地登出(仅清前端,不通知后端),用于路由守卫等需要立即生效的场景 */
|
|
function logoutLocal(): void {
|
|
clearAuthState()
|
|
}
|
|
|
|
async function logout(): Promise<void> {
|
|
try {
|
|
await request.post('/admin/auth/logout')
|
|
} catch {
|
|
// best-effort
|
|
}
|
|
clearAuthState()
|
|
}
|
|
|
|
async function refreshTokens(): Promise<void> {
|
|
const { data } = await request.post<{ data: LoginResponse }>('/admin/auth/refresh', {
|
|
refresh_token: refreshToken.value,
|
|
})
|
|
applySession(data.data)
|
|
}
|
|
|
|
function loadFromStorage(): void {
|
|
hydrate()
|
|
}
|
|
|
|
// Sync Pinia store when Axios interceptor refreshes tokens
|
|
onTokenRefreshed((tokens) => {
|
|
token.value = tokens.accessToken
|
|
refreshToken.value = tokens.refreshToken
|
|
expiresAt.value = tokens.expiresAt
|
|
persist()
|
|
})
|
|
|
|
// Axios 拦截器在 401 认证彻底失效时回调,由 store 清空全部登录数据
|
|
// (必须先清后跳,否则路由守卫会从持久化数据恢复登录态,造成登录页↔系统死循环)
|
|
onAuthExpired(() => {
|
|
clearAuthState()
|
|
})
|
|
|
|
function clearPendingAgreements(): void {
|
|
pendingAgreements.value = []
|
|
}
|
|
|
|
return {
|
|
token,
|
|
refreshToken,
|
|
expiresAt,
|
|
user,
|
|
permissions,
|
|
pendingAgreements,
|
|
rememberMe,
|
|
isLoggedIn,
|
|
isSuperAdmin,
|
|
login,
|
|
applyTokensFrom2FA,
|
|
logout,
|
|
logoutLocal,
|
|
refreshTokens,
|
|
loadFromStorage,
|
|
clearPendingAgreements,
|
|
}
|
|
})
|