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(['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(null) const user = ref(null) const profile = ref({ ...emptyProfile }) const permissionCodes = ref([]) const sessionRestored = ref(false) let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise } | undefined let generation = 0 let contextRequest: { generation: number; promise: Promise } | undefined let restoreTimer: ReturnType | 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) { profile.value = { ...profile.value, ...structuredClone(next) } if (user.value) user.value = { ...user.value, name: next.realName } } async function savePersonalProfile(next: Pick) { 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 } })