feat: share heaps and folders with other users, fix auth and vision pipeline
Sharing:
- New HeapShare and FolderShare models with read/write permissions
- Sharing API router (CRUD for heap and folder shares)
- Heap endpoints accept shared access (photo_ids, add/remove with write)
- Photo list drops user_id filter in shared context, adds owner_username
- Media serving (thumb/original/proxy) falls back to share check on 404
- ShareDialog component for managing shares from kebab menus
- HeapsPanel shows "Shared with me" section for shared heaps
- LeftSidebar shows "Shared with me" section for shared folders
- Owner badge on PhotoThumbnail for photos from other users
Auth:
- Access token default bumped to 1 year, refresh to 10 years
- Refresh token persisted in localStorage (survives page reload)
- Timer-based refresh replaced with 401 axios interceptor
Vision pipeline fixes:
- Bootstrap sets Redis ready key even on partial export failure
- Export functions run conditionally (only for actually missing models)
- _load_thumb handles multi-user path (/data/thumbs/{user_id}/{photo_id}/)
- can_access_photo_via_share uses single subquery instead of N+1 loop
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,6 @@ import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import api from '../services/api'
|
||||
@@ -47,8 +46,17 @@ function storeToken(token: string) {
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
function clearToken() {
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
@@ -57,48 +65,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [needsSetup, setNeedsSetup] = useState(false)
|
||||
// Keep refresh token in memory only (not localStorage).
|
||||
const refreshTokenRef = useRef<string | null>(null)
|
||||
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const isAdmin = user?.role === 'admin'
|
||||
|
||||
// Schedule a token refresh ~5 min before expiry.
|
||||
const scheduleRefresh = useCallback((accessToken: string) => {
|
||||
try {
|
||||
const payload = JSON.parse(atob(accessToken.split('.')[1]))
|
||||
const expiresAt = payload.exp * 1000
|
||||
const refreshIn = Math.max(expiresAt - Date.now() - 5 * 60 * 1000, 10_000)
|
||||
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
||||
refreshTimerRef.current = setTimeout(async () => {
|
||||
if (!refreshTokenRef.current) return
|
||||
try {
|
||||
const res = await api.post('/auth/refresh', {
|
||||
refresh_token: refreshTokenRef.current,
|
||||
})
|
||||
const { access_token, refresh_token } = res.data
|
||||
storeToken(access_token)
|
||||
refreshTokenRef.current = refresh_token
|
||||
scheduleRefresh(access_token)
|
||||
} catch {
|
||||
// Refresh failed — force re-login.
|
||||
clearToken()
|
||||
refreshTokenRef.current = null
|
||||
setUser(null)
|
||||
}
|
||||
}, refreshIn)
|
||||
} catch {
|
||||
// Malformed token — ignore.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const fetchMe = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get('/auth/me')
|
||||
setUser(res.data)
|
||||
} catch {
|
||||
clearToken()
|
||||
clearTokens()
|
||||
setUser(null)
|
||||
}
|
||||
}, [])
|
||||
@@ -120,46 +95,71 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const token = getStoredToken()
|
||||
if (token) {
|
||||
await fetchMe()
|
||||
scheduleRefresh(token)
|
||||
}
|
||||
setIsLoading(false)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
||||
}
|
||||
}, [fetchMe, scheduleRefresh])
|
||||
}, [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)
|
||||
refreshTokenRef.current = refresh_token
|
||||
scheduleRefresh(access_token)
|
||||
storeRefreshToken(refresh_token)
|
||||
await fetchMe()
|
||||
},
|
||||
[fetchMe, scheduleRefresh],
|
||||
[fetchMe],
|
||||
)
|
||||
|
||||
const logout = useCallback(() => {
|
||||
clearToken()
|
||||
refreshTokenRef.current = null
|
||||
if (refreshTimerRef.current) clearTimeout(refreshTimerRef.current)
|
||||
clearTokens()
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const onSetupComplete = useCallback(
|
||||
async (accessToken: string, refreshToken: string) => {
|
||||
storeToken(accessToken)
|
||||
refreshTokenRef.current = refreshToken
|
||||
storeRefreshToken(refreshToken)
|
||||
setNeedsSetup(false)
|
||||
scheduleRefresh(accessToken)
|
||||
await fetchMe()
|
||||
},
|
||||
[fetchMe, scheduleRefresh],
|
||||
[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/')
|
||||
) {
|
||||
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 }}
|
||||
|
||||
Reference in New Issue
Block a user