Files
mule-image/frontend/src/contexts/AuthContext.tsx
root ecd8bbe61d fix: HEIC vips fallback, DB pool exhaustion, auth session persistence
HEIC thumbnails:
- Switch fallback from ffmpeg to vips for HEIC files that pillow-heif
  rejects. ffmpeg decoded gain map tiles instead of the primary image,
  producing inverted/negative thumbnails. vips uses libheif's item
  references correctly and extracts the full-resolution primary image.

Database pool exhaustion:
- Add idle_in_transaction_session_timeout=60s so Postgres auto-kills
  leaked connections from disconnected thumbnail requests.
- Add pool_timeout=10 so new requests fail fast instead of hanging.
- Bump pool from 5+5 to 10+10 for thumbnail concurrency headroom.
- get_db rolls back on exception before closing.

Auth session persistence:
- Narrow 401 interceptor exclusion to only /auth/refresh and /auth/login
  (was excluding all /auth/* including /auth/me, preventing token refresh
  on boot).
- fetchMe only clears tokens on 401/403, not network errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 15:25:55 +02:00

177 lines
5.0 KiB
TypeScript

import {
createContext,
useContext,
useState,
useEffect,
useCallback,
type ReactNode,
} from 'react'
import api from '../services/api'
export interface AuthUser {
id: string
username: string
email: string | null
role: 'admin' | 'user'
is_active: boolean
}
interface AuthContextValue {
user: AuthUser | null
isAdmin: boolean
isLoading: boolean
/** True when the backend has no users yet (first-run). */
needsSetup: boolean
login: (username: string, password: string) => Promise<void>
logout: () => void
/** Called after the setup endpoint creates the first admin. */
onSetupComplete: (accessToken: string, refreshToken: string) => Promise<void>
}
const AuthContext = createContext<AuthContextValue | null>(null)
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext)
if (!ctx) throw new Error('useAuth must be used within AuthProvider')
return ctx
}
// ── Token helpers ──────────────────────────────────────────────────────
function getStoredToken(): string | null {
return localStorage.getItem('access_token')
}
function storeToken(token: string) {
localStorage.setItem('access_token', token)
}
function getStoredRefreshToken(): string | null {
return localStorage.getItem('refresh_token')
}
function storeRefreshToken(token: string) {
localStorage.setItem('refresh_token', token)
}
function clearTokens() {
localStorage.removeItem('access_token')
localStorage.removeItem('refresh_token')
}
// ── Provider ───────────────────────────────────────────────────────────
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [needsSetup, setNeedsSetup] = useState(false)
const isAdmin = user?.role === 'admin'
const fetchMe = useCallback(async () => {
try {
const res = await api.get('/auth/me')
setUser(res.data)
} catch (err: any) {
// Only clear tokens on auth failure (401/403), not network errors.
if (err?.response?.status === 401 || err?.response?.status === 403) {
clearTokens()
setUser(null)
}
// Network errors: leave tokens in place, user stays on login screen
// but can retry without re-entering credentials.
}
}, [])
// Boot: check if setup is needed, then try to restore session.
useEffect(() => {
;(async () => {
try {
const statusRes = await api.get('/auth/status')
if (!statusRes.data.setup_completed) {
setNeedsSetup(true)
setIsLoading(false)
return
}
} catch {
// Backend unreachable — fall through to login screen.
}
const token = getStoredToken()
if (token) {
await fetchMe()
}
setIsLoading(false)
})()
}, [fetchMe])
const login = useCallback(
async (username: string, password: string) => {
const res = await api.post('/auth/login', { username, password })
const { access_token, refresh_token } = res.data
storeToken(access_token)
storeRefreshToken(refresh_token)
await fetchMe()
},
[fetchMe],
)
const logout = useCallback(() => {
clearTokens()
setUser(null)
}, [])
const onSetupComplete = useCallback(
async (accessToken: string, refreshToken: string) => {
storeToken(accessToken)
storeRefreshToken(refreshToken)
setNeedsSetup(false)
await fetchMe()
},
[fetchMe],
)
// Axios interceptor: on 401, try to refresh once using the stored
// refresh token. If that fails, sign out.
useEffect(() => {
const interceptor = api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config
if (
error.response?.status === 401 &&
!original._retry &&
!original.url?.includes('/auth/refresh') &&
!original.url?.includes('/auth/login')
) {
original._retry = true
const rt = getStoredRefreshToken()
if (rt) {
try {
const res = await api.post('/auth/refresh', { refresh_token: rt })
const { access_token, refresh_token } = res.data
storeToken(access_token)
storeRefreshToken(refresh_token)
original.headers['Authorization'] = `Bearer ${access_token}`
return api(original)
} catch {
clearTokens()
setUser(null)
}
}
}
return Promise.reject(error)
},
)
return () => api.interceptors.response.eject(interceptor)
}, [])
return (
<AuthContext.Provider
value={{ user, isAdmin, isLoading, needsSetup, login, logout, onSetupComplete }}
>
{children}
</AuthContext.Provider>
)
}