Two root causes fixed: 1. api.ts saveToken() updated only localStorage, not React state — WS kept reconnecting with the expired token. Now dispatches hotelsync:token-updated event; AuthContext listens and updates session state, triggering WS reconnect with the fresh token. 2. AuthContext mount effect could race with login(): if refresh failed while login() was concurrently setting a new token, catch() called setSession(null) and wiped the fresh session. Fixed with functional setSession updater that checks current state before clearing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
import { createContext, useContext, useEffect, useState } from 'react'
|
|
import type { User, AuthSession } from '../types'
|
|
import { api, ApiError } from '../lib/api'
|
|
|
|
const BASE = (import.meta.env.VITE_API_URL as string | undefined) ?? 'https://api.hotelsync.ru'
|
|
|
|
interface AuthContextValue {
|
|
session: AuthSession | null
|
|
user: User | null
|
|
login: (email: string, password: string) => Promise<User | null>
|
|
logout: () => void
|
|
}
|
|
|
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
|
|
|
function loadSession(): AuthSession | null {
|
|
try {
|
|
const stored = localStorage.getItem('hotelsync-session')
|
|
if (!stored) return null
|
|
const parsed = JSON.parse(stored) as AuthSession
|
|
// If session is missing token — discard it, we'll refresh below
|
|
if (!parsed?.user) { localStorage.removeItem('hotelsync-session'); return null }
|
|
return parsed
|
|
} catch {
|
|
localStorage.removeItem('hotelsync-session')
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|
const [session, setSession] = useState<AuthSession | null>(loadSession)
|
|
|
|
// On mount: if session exists but token is missing/null, try to refresh via cookie
|
|
useEffect(() => {
|
|
if (!session) return
|
|
if (session.token) return // token present, all good
|
|
|
|
fetch(`${BASE}/api/auth/refresh`, { method: 'POST', credentials: 'include' })
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then((data: { access_token?: string } | null) => {
|
|
if (data?.access_token) {
|
|
const tok = data.access_token
|
|
setSession(cur => {
|
|
if (!cur) return null
|
|
const s: AuthSession = { ...cur, token: tok }
|
|
localStorage.setItem('hotelsync-session', JSON.stringify(s))
|
|
return s
|
|
})
|
|
} else {
|
|
// Guard: if login() set a fresh token while refresh was in-flight — keep it
|
|
setSession(cur => {
|
|
if (cur?.token) return cur
|
|
localStorage.removeItem('hotelsync-session')
|
|
return null
|
|
})
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// Guard: same race-condition protection
|
|
setSession(cur => {
|
|
if (cur?.token) return cur
|
|
localStorage.removeItem('hotelsync-session')
|
|
return null
|
|
})
|
|
})
|
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Keep React state in sync when api.ts refreshes the token (e.g. after 401 auto-refresh)
|
|
useEffect(() => {
|
|
const handler = (e: Event) => {
|
|
const { token } = (e as CustomEvent<{ token: string }>).detail
|
|
setSession(cur => {
|
|
if (!cur) return cur
|
|
return { ...cur, token }
|
|
})
|
|
}
|
|
window.addEventListener('hotelsync:token-updated', handler)
|
|
return () => window.removeEventListener('hotelsync:token-updated', handler)
|
|
}, [])
|
|
|
|
const login = async (email: string, password: string): Promise<User | null> => {
|
|
try {
|
|
const { access_token, user } = await api.auth.login(email, password)
|
|
const s: AuthSession = { user, token: access_token }
|
|
setSession(s)
|
|
localStorage.setItem('hotelsync-session', JSON.stringify(s))
|
|
return user
|
|
} catch (err) {
|
|
if (err instanceof ApiError && err.status === 401) return null
|
|
throw err
|
|
}
|
|
}
|
|
|
|
const logout = async () => {
|
|
try { await api.auth.logout() } catch { /* ignore */ }
|
|
setSession(null)
|
|
localStorage.removeItem('hotelsync-session')
|
|
}
|
|
|
|
return (
|
|
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, logout }}>
|
|
{children}
|
|
</AuthContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useAuth() {
|
|
const ctx = useContext(AuthContext)
|
|
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
|
|
return ctx
|
|
}
|