fix(auth): restore cross-tab permissions and bind retries to session identity

This commit is contained in:
2026-10-02 23:29:20 +08:00
parent e564353a80
commit 5f963f326e
7 changed files with 386 additions and 109 deletions
+93 -98
View File
@@ -61,18 +61,33 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false)
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
profile.value.realName = decoded.username
permissionCodes.value = mapPermissions()
sessionRestored.value = false
}
function clearSession() {
clearSessionTokens()
function resetLocalSession() {
generation++
token.value = ''
payload.value = null
user.value = null
@@ -81,137 +96,113 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
function clearSession() {
clearSessionTokens()
resetLocalSession()
}
function applyStoredSession() {
const storedToken = getAccessToken()
if (!storedToken) {
token.value = ''
payload.value = null
user.value = null
permissionCodes.value = []
profile.value = { ...emptyProfile }
sessionRestored.value = true
resetLocalSession()
return
}
try {
applyToken(storedToken)
sessionRestored.value = false
} catch {
clearSession()
}
}
if (token.value) {
try {
applyToken(token.value)
} catch (error) {
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
resetLocalSession()
if (error instanceof MerchantTokenExpiredError && getRefreshToken()) sessionRestored.value = false
}
}
applyStoredSession()
const isAuthenticated = computed(() => Boolean(token.value && payload.value))
async function loadSessionContext() {
if (!payload.value) return
const currentPermissionCodes = await getCurrentMerchantPermissionCodes()
const currentAccessToken = getAccessToken()
if (currentAccessToken && currentAccessToken !== token.value) applyToken(currentAccessToken)
permissionCodes.value = mapPermissions(currentPermissionCodes)
const [tenant, personalProfile] = await Promise.all([
getTenantProfile(),
getMerchantPersonalProfile(),
])
user.value = currentUser(payload.value, tenant.tenantName)
profile.value = {
...profile.value,
realName: personalProfile.displayName || payload.value.username,
phone: personalProfile.phone || '',
email: personalProfile.email || '',
tenantName: tenant.tenantName,
}
}
async function restoreSession() {
if (sessionRestored.value) return
if (!isAuthenticated.value) {
if (!getRefreshToken()) {
sessionRestored.value = true
return
}
if (contextRequest?.generation === generation) return contextRequest.promise
let requestGeneration = generation
let requestAccessToken = getAccessToken()
const isCurrent = () => requestGeneration === generation && getAccessToken() === requestAccessToken
const promise = (async () => {
try {
applyToken(await refreshAuthenticatedSession())
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
}
sessionRestored.value = true
return
}
})()
contextRequest = { generation: requestGeneration, promise }
try { await promise } finally {
if (contextRequest?.promise === promise) contextRequest = undefined
}
try {
await loadSessionContext()
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
return
}
sessionRestored.value = true
}
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)
const decoded = decodeMerchantToken(tokens.accessToken)
if (attempt !== generation) return
decodeMerchantToken(tokens.accessToken)
persistTokenPair(tokens)
persistRememberedLoginId(loginId, remember)
token.value = tokens.accessToken
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
sessionRestored.value = true
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
sessionRestored.value = true
}
applyToken(tokens.accessToken)
await restoreSession()
}
async function acceptImpersonationCode(code: string) {
const attempt = ++generation
const exchanged = await exchangeImpersonationCode(code)
const decoded = decodeMerchantToken(exchanged.accessToken)
if (attempt !== generation) return
decodeMerchantToken(exchanged.accessToken)
persistTemporaryAccessToken(exchanged.accessToken)
token.value = exchanged.accessToken
payload.value = decoded
user.value = currentUser(decoded)
profile.value = { ...emptyProfile, username: decoded.username, realName: decoded.username }
permissionCodes.value = mapPermissions()
sessionRestored.value = false
try {
await loadSessionContext()
} catch (error) {
if (isExpiredSessionError(error)) {
clearSession()
throw error
}
}
sessionRestored.value = true
applyToken(exchanged.accessToken)
await restoreSession()
}
async function logout() {
const refreshToken = getRefreshToken()
try {
if (refreshToken) await logoutMerchant(refreshToken)
} finally {
clearSession()
}
clearSession()
if (refreshToken) await logoutMerchant(refreshToken)
}
function hasPermission(permission?: string) {
@@ -224,10 +215,13 @@ export const useAuthStore = defineStore('client-auth', () => {
}
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 || '',
@@ -236,8 +230,9 @@ export const useAuthStore = defineStore('client-auth', () => {
}
window.addEventListener('cc-client-session-expired', clearSession)
window.addEventListener('cc-client-session-updated', synchronizeSession)
window.addEventListener('storage', (event) => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
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 }