Source-reviewed hardening changes and regression tests. UNBUILT / UNTESTED: not a deployable release. Go formatting, compilation, automated regression tests, full frontend validation and staging acceptance remain required. See docs/hardening/VALIDATION.md. Main remains the original clean snapshot; no upstream Git history imported.
151 lines
4.9 KiB
TypeScript
151 lines
4.9 KiB
TypeScript
// Modified in the independent hardening fork; see docs/hardening/README.md.
|
|
// Modified for AgentDeskFixes: invalidate stale profile/sign-out work and clear private account state.
|
|
"use client"
|
|
|
|
import { Fragment, createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from "react"
|
|
|
|
import { fetchProfile, logout } from "@/lib/api/auth"
|
|
import {
|
|
AUTH_SESSION_CHANGED_EVENT,
|
|
AUTH_SESSION_EXPIRED_EVENT,
|
|
clearSession,
|
|
readSession,
|
|
writeSession,
|
|
type AuthSession,
|
|
} from "@/lib/auth"
|
|
import { createSessionRequestGuard } from "@/lib/session-request-guard"
|
|
import { resetAgentConversations } from "@/lib/stores/agent-conversations"
|
|
|
|
type SessionContextValue = {
|
|
session: AuthSession | null
|
|
ready: boolean
|
|
signingOut: boolean
|
|
refreshSession: () => Promise<void>
|
|
signOut: () => Promise<void>
|
|
}
|
|
|
|
const SessionContext = createContext<SessionContextValue | null>(null)
|
|
|
|
function accountKey(session: AuthSession | null) {
|
|
return session ? `${session.user.userType}:${session.user.id}` : "anonymous"
|
|
}
|
|
|
|
export function SessionProvider({ children }: { children: ReactNode }) {
|
|
const [session, setSession] = useState<AuthSession | null>(null)
|
|
const [ready, setReady] = useState(false)
|
|
const [signingOut, setSigningOut] = useState(false)
|
|
const [requestGuard] = useState(() => createSessionRequestGuard(readSession))
|
|
const identityRef = useRef<string | null>(null)
|
|
const signOutPendingRef = useRef(false)
|
|
const signOutGenerationRef = useRef(0)
|
|
|
|
const applySession = useCallback((next: AuthSession | null) => {
|
|
const nextIdentity = accountKey(next)
|
|
if (identityRef.current !== nextIdentity) {
|
|
resetAgentConversations()
|
|
identityRef.current = nextIdentity
|
|
}
|
|
setSession(next)
|
|
setReady(true)
|
|
}, [])
|
|
|
|
const refreshSession = useCallback(async () => {
|
|
const stored = readSession()
|
|
if (!stored) {
|
|
requestGuard.invalidate()
|
|
applySession(null)
|
|
return
|
|
}
|
|
const isCurrent = requestGuard.begin(stored)
|
|
|
|
try {
|
|
const profile = await fetchProfile()
|
|
if (!isCurrent()) return
|
|
const nextSession: AuthSession = {
|
|
...stored,
|
|
user: profile.user,
|
|
permissions: profile.permissions,
|
|
roles: profile.roles,
|
|
accessToken: profile.accessToken || stored.accessToken,
|
|
expiresAt: profile.expiresAt || stored.expiresAt,
|
|
}
|
|
applySession(nextSession)
|
|
writeSession(nextSession)
|
|
} catch (error) {
|
|
if (!isCurrent()) return
|
|
const errorCode = (error as Error & { errorCode?: number }).errorCode
|
|
if (errorCode === 3000 || errorCode === 3002) {
|
|
clearSession()
|
|
applySession(null)
|
|
} else {
|
|
applySession(stored)
|
|
}
|
|
} finally {
|
|
if (isCurrent()) setReady(true)
|
|
}
|
|
}, [applySession, requestGuard])
|
|
|
|
const signOut = useCallback(async () => {
|
|
if (signOutPendingRef.current) return
|
|
signOutPendingRef.current = true
|
|
const generation = ++signOutGenerationRef.current
|
|
setSigningOut(true)
|
|
requestGuard.invalidate()
|
|
// Start the request with the old token, then immediately remove local access.
|
|
const pending = logout()
|
|
clearSession()
|
|
applySession(null)
|
|
try {
|
|
await pending
|
|
} catch {
|
|
// Local sign-out already completed; a late error cannot clear a new login.
|
|
} finally {
|
|
if (generation === signOutGenerationRef.current) {
|
|
signOutPendingRef.current = false
|
|
setSigningOut(false)
|
|
}
|
|
}
|
|
}, [applySession, requestGuard])
|
|
|
|
useEffect(() => {
|
|
const syncSession = () => {
|
|
requestGuard.invalidate()
|
|
const current = readSession()
|
|
if (current && signOutPendingRef.current) {
|
|
signOutGenerationRef.current += 1
|
|
signOutPendingRef.current = false
|
|
setSigningOut(false)
|
|
}
|
|
applySession(current)
|
|
}
|
|
const syncOtherTab = (event: StorageEvent) => {
|
|
if (event.key === "agent-desk-session" || event.key === null) syncSession()
|
|
}
|
|
|
|
window.addEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
|
window.addEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
|
window.addEventListener("storage", syncOtherTab)
|
|
applySession(readSession())
|
|
void refreshSession()
|
|
return () => {
|
|
requestGuard.invalidate()
|
|
signOutGenerationRef.current += 1
|
|
window.removeEventListener(AUTH_SESSION_CHANGED_EVENT, syncSession)
|
|
window.removeEventListener(AUTH_SESSION_EXPIRED_EVENT, syncSession)
|
|
window.removeEventListener("storage", syncOtherTab)
|
|
}
|
|
}, [applySession, refreshSession, requestGuard])
|
|
|
|
return (
|
|
<SessionContext.Provider value={{ session, ready, signingOut, refreshSession, signOut }}>
|
|
<Fragment key={accountKey(session)}>{children}</Fragment>
|
|
</SessionContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useSession() {
|
|
const context = useContext(SessionContext)
|
|
if (!context) throw new Error("useSession must be used within SessionProvider")
|
|
return context
|
|
}
|