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>
177 lines
5.0 KiB
TypeScript
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>
|
|
)
|
|
}
|