251 lines
9.2 KiB
TypeScript
251 lines
9.2 KiB
TypeScript
import { computed, ref } from 'vue'
|
|
import { defineStore } from 'pinia'
|
|
import type { CurrentUser } from '@chongchong/shared'
|
|
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
|
import { ApiError, refreshAuthenticatedSession } from '@/api/http'
|
|
import {
|
|
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
|
hasMappedMerchantPermission,
|
|
getCurrentMerchantPermissionCodes,
|
|
getMerchantPersonalProfile,
|
|
getTenantProfile,
|
|
updateMerchantPersonalProfile,
|
|
} from '@/modules/tenant-management/public'
|
|
import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
|
import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
|
|
|
export interface ClientAccountProfile {
|
|
username: string
|
|
realName: string
|
|
phone: string
|
|
email: string
|
|
tenantName: string
|
|
}
|
|
|
|
const emptyProfile: ClientAccountProfile = {
|
|
username: '',
|
|
realName: '',
|
|
phone: '',
|
|
email: '',
|
|
tenantName: '',
|
|
}
|
|
|
|
function mapPermissions(backendPermissionCodes: string[] = []) {
|
|
const backendPermissions = new Set(backendPermissionCodes)
|
|
const result = new Set<string>(['dashboard:view', 'account:profile:view'])
|
|
for (const [frontendCode] of Object.entries(FRONTEND_TO_MERCHANT_PERMISSION_CODES)) {
|
|
if (hasMappedMerchantPermission(frontendCode, backendPermissions)) result.add(frontendCode)
|
|
}
|
|
return [...result]
|
|
}
|
|
|
|
function currentUser(payload: MerchantJwtPayload, tenantName?: string): CurrentUser {
|
|
return {
|
|
id: String(payload.user_id),
|
|
name: payload.username,
|
|
account: payload.username,
|
|
tenantName: tenantName || `企业租户 ${payload.tenant_id}`,
|
|
roles: [],
|
|
}
|
|
}
|
|
|
|
function isExpiredSessionError(error: unknown) {
|
|
return error instanceof ApiError && (error.status === 401 || error.code === 'A0401')
|
|
}
|
|
|
|
export const useAuthStore = defineStore('client-auth', () => {
|
|
const token = ref(getAccessToken())
|
|
const payload = ref<MerchantJwtPayload | null>(null)
|
|
const user = ref<CurrentUser | null>(null)
|
|
const profile = ref<ClientAccountProfile>({ ...emptyProfile })
|
|
const permissionCodes = ref<string[]>([])
|
|
const sessionRestored = ref(false)
|
|
|
|
let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise<void> } | undefined
|
|
let generation = 0
|
|
let contextRequest: { generation: number; promise: Promise<void> } | undefined
|
|
let restoreTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
function identity(value: MerchantJwtPayload | null) {
|
|
return value ? JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type]) : ''
|
|
}
|
|
|
|
function applyToken(accessToken: string) {
|
|
const decoded = decodeMerchantToken(accessToken)
|
|
const changedIdentity = identity(decoded) !== identity(payload.value)
|
|
if (accessToken === token.value && payload.value && !changedIdentity) return
|
|
generation++
|
|
if (changedIdentity) {
|
|
user.value = null
|
|
profile.value = { ...emptyProfile }
|
|
permissionCodes.value = mapPermissions()
|
|
}
|
|
payload.value = decoded
|
|
token.value = accessToken
|
|
user.value = currentUser(decoded, user.value?.tenantName)
|
|
profile.value.username = decoded.username
|
|
sessionRestored.value = false
|
|
}
|
|
|
|
function resetLocalSession() {
|
|
generation++
|
|
token.value = ''
|
|
payload.value = null
|
|
user.value = null
|
|
permissionCodes.value = []
|
|
profile.value = { ...emptyProfile }
|
|
sessionRestored.value = true
|
|
}
|
|
|
|
function clearSession() {
|
|
clearSessionTokens()
|
|
resetLocalSession()
|
|
}
|
|
|
|
function applyStoredSession() {
|
|
const storedToken = getAccessToken()
|
|
if (!storedToken) {
|
|
resetLocalSession()
|
|
return
|
|
}
|
|
try {
|
|
applyToken(storedToken)
|
|
} catch (error) {
|
|
resetLocalSession()
|
|
if (error instanceof MerchantTokenExpiredError && getRefreshToken()) sessionRestored.value = false
|
|
}
|
|
}
|
|
|
|
applyStoredSession()
|
|
const isAuthenticated = computed(() => Boolean(token.value && payload.value))
|
|
|
|
async function restoreSession() {
|
|
if (sessionRestored.value) return
|
|
if (contextRequest?.generation === generation) return contextRequest.promise
|
|
let requestGeneration = generation
|
|
let requestAccessToken = getAccessToken()
|
|
const isCurrent = () => requestGeneration === generation && getAccessToken() === requestAccessToken
|
|
const promise = (async () => {
|
|
try {
|
|
if (!isAuthenticated.value) {
|
|
if (!getRefreshToken()) return
|
|
const refreshedToken = await refreshAuthenticatedSession()
|
|
if (requestGeneration !== generation) return restoreSession()
|
|
applyToken(refreshedToken)
|
|
requestAccessToken = refreshedToken
|
|
requestGeneration = generation
|
|
if (contextRequest) contextRequest.generation = generation
|
|
}
|
|
const requestPayload = payload.value
|
|
if (!requestPayload) return
|
|
const [codes, tenant, personalProfile] = await Promise.all([
|
|
getCurrentMerchantPermissionCodes(), getTenantProfile(), getMerchantPersonalProfile(),
|
|
])
|
|
if (!isCurrent()) {
|
|
applyStoredSession()
|
|
return restoreSession()
|
|
}
|
|
permissionCodes.value = mapPermissions(codes)
|
|
user.value = currentUser(requestPayload, tenant.tenantName)
|
|
profile.value = {
|
|
username: requestPayload.username,
|
|
realName: personalProfile.displayName || requestPayload.username,
|
|
phone: personalProfile.phone || '', email: personalProfile.email || '', tenantName: tenant.tenantName,
|
|
}
|
|
sessionRestored.value = true
|
|
} catch (error) {
|
|
if (!isCurrent()) return
|
|
sessionRestored.value = false
|
|
if (isExpiredSessionError(error)) {
|
|
clearSession()
|
|
throw error
|
|
}
|
|
}
|
|
})()
|
|
contextRequest = { generation: requestGeneration, promise }
|
|
try { await promise } finally {
|
|
if (contextRequest?.promise === promise) contextRequest = undefined
|
|
}
|
|
}
|
|
|
|
function synchronizeSession() {
|
|
// Identity changes and logout revoke local rights immediately. Coalesce pair writes
|
|
// before loading the new context; same-identity rotation retains confirmed rights.
|
|
applyStoredSession()
|
|
if (restoreTimer) clearTimeout(restoreTimer)
|
|
restoreTimer = setTimeout(() => {
|
|
restoreTimer = undefined
|
|
void restoreSession().catch(() => {})
|
|
}, 0)
|
|
}
|
|
|
|
async function login(loginId: string, password: string, remember = false) {
|
|
const attempt = ++generation
|
|
const tokens = await loginMerchant(loginId, password)
|
|
if (attempt !== generation) return
|
|
decodeMerchantToken(tokens.accessToken)
|
|
persistTokenPair(tokens)
|
|
persistRememberedLoginId(loginId, remember)
|
|
applyToken(tokens.accessToken)
|
|
await restoreSession()
|
|
}
|
|
|
|
async function acceptImpersonationCode(code: string) {
|
|
const attempt = ++generation
|
|
const exchanged = await exchangeImpersonationCode(code)
|
|
if (attempt !== generation) return
|
|
decodeMerchantToken(exchanged.accessToken)
|
|
persistTemporaryAccessToken(exchanged.accessToken)
|
|
applyToken(exchanged.accessToken)
|
|
await restoreSession()
|
|
}
|
|
|
|
function logout() {
|
|
const accessToken = getAccessToken()
|
|
const refreshToken = getRefreshToken()
|
|
if (logoutRequest && ((!accessToken && !refreshToken)
|
|
|| (logoutRequest.accessToken === accessToken && logoutRequest.refreshToken === refreshToken))) return logoutRequest.promise
|
|
const pending = accessToken || refreshToken
|
|
? logoutMerchant(accessToken, refreshToken)
|
|
: Promise.resolve()
|
|
clearSession()
|
|
const request = { accessToken, refreshToken, promise: pending.finally(() => {
|
|
if (logoutRequest === request) logoutRequest = undefined
|
|
}) }
|
|
logoutRequest = request
|
|
return request.promise
|
|
}
|
|
|
|
function hasPermission(permission?: string) {
|
|
return !permission || permissionCodes.value.includes(permission)
|
|
}
|
|
|
|
function updateProfile(next: Pick<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
|
|
profile.value = { ...profile.value, ...structuredClone(next) }
|
|
if (user.value) user.value = { ...user.value, name: next.realName }
|
|
}
|
|
|
|
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'email'>) {
|
|
const requestGeneration = generation
|
|
const requestToken = getAccessToken()
|
|
const updated = await updateMerchantPersonalProfile({
|
|
displayName: next.realName.trim(),
|
|
email: next.email.trim(),
|
|
})
|
|
if (requestGeneration !== generation || requestToken !== getAccessToken()) return
|
|
updateProfile({
|
|
realName: updated.displayName,
|
|
phone: updated.phone || '',
|
|
email: updated.email || '',
|
|
})
|
|
}
|
|
|
|
window.addEventListener('cc-client-session-expired', clearSession)
|
|
window.addEventListener('cc-client-session-updated', synchronizeSession)
|
|
window.addEventListener('storage', (event) => {
|
|
if (event.storageArea === localStorage && (event.key === null || event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token')) synchronizeSession()
|
|
})
|
|
|
|
return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
|
|
})
|