From 1a4c04e239865e406d2c9844c889677716978224 Mon Sep 17 00:00:00 2001 From: jubnl Date: Wed, 1 Apr 2026 21:19:53 +0200 Subject: [PATCH] fix: resolve Immich 401 passthrough causing spurious login redirects - Auth middleware now tags its 401s with code: AUTH_REQUIRED so the client interceptor only redirects to /login on genuine session failures, not on upstream API errors - Fix /albums and album sync routes using raw encrypted API key instead of getImmichCredentials() (which decrypts it), causing Immich to reject requests with 401 - Add toast error notifications for all Immich operations in MemoriesPanel that previously swallowed errors silently Co-Authored-By: Claude Sonnet 4.6 --- client/src/api/client.ts | 2 +- .../src/components/Memories/MemoriesPanel.tsx | 17 ++++++++++------- client/src/i18n/translations/en.ts | 8 ++++++++ server/src/middleware/auth.ts | 6 +++--- server/src/routes/immich.ts | 16 ++++++++-------- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 7992005..a901a5b 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -25,7 +25,7 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( (response) => response, (error) => { - if (error.response?.status === 401) { + if (error.response?.status === 401 && (error.response?.data as { code?: string } | undefined)?.code === 'AUTH_REQUIRED') { if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) { window.location.href = '/login' } diff --git a/client/src/components/Memories/MemoriesPanel.tsx b/client/src/components/Memories/MemoriesPanel.tsx index b45c82e..9dd1ed4 100644 --- a/client/src/components/Memories/MemoriesPanel.tsx +++ b/client/src/components/Memories/MemoriesPanel.tsx @@ -4,6 +4,7 @@ import apiClient from '../../api/client' import { useAuthStore } from '../../store/authStore' import { useTranslation } from '../../i18n' import { getAuthUrl } from '../../api/authUrl' +import { useToast } from '../shared/Toast' function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) { const [src, setSrc] = useState('') @@ -40,6 +41,7 @@ interface MemoriesPanelProps { export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) { const { t } = useTranslation() + const toast = useToast() const currentUser = useAuthStore(s => s.user) const [connected, setConnected] = useState(false) @@ -81,7 +83,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa try { const res = await apiClient.get('/integrations/immich/albums') setAlbums(res.data.albums || []) - } catch { setAlbums([]) } + } catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) } finally { setAlbumsLoading(false) } } @@ -94,14 +96,14 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa const linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`) const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId) if (newLink) await syncAlbum(newLink.id) - } catch {} + } catch { toast.error(t('memories.error.linkAlbum')) } } const unlinkAlbum = async (linkId: number) => { try { await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`) loadAlbumLinks() - } catch {} + } catch { toast.error(t('memories.error.unlinkAlbum')) } } const syncAlbum = async (linkId: number) => { @@ -110,7 +112,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa await apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`) await loadAlbumLinks() await loadPhotos() - } catch {} + } catch { toast.error(t('memories.error.syncAlbum')) } finally { setSyncing(null) } } @@ -178,6 +180,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setPickerPhotos(res.data.assets || []) } catch { setPickerPhotos([]) + toast.error(t('memories.error.loadPhotos')) } finally { setPickerLoading(false) } @@ -206,7 +209,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa }) setShowPicker(false) loadInitial() - } catch {} + } catch { toast.error(t('memories.error.addPhotos')) } } // ── Remove photo ────────────────────────────────────────────────────────── @@ -215,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa try { await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`) setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId)) - } catch {} + } catch { toast.error(t('memories.error.removePhoto')) } } // ── Toggle sharing ──────────────────────────────────────────────────────── @@ -226,7 +229,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa setTripPhotos(prev => prev.map(p => p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p )) - } catch {} + } catch { toast.error(t('memories.error.toggleSharing')) } } // ── Helpers ─────────────────────────────────────────────────────────────── diff --git a/client/src/i18n/translations/en.ts b/client/src/i18n/translations/en.ts index 632a8c3..336ea72 100644 --- a/client/src/i18n/translations/en.ts +++ b/client/src/i18n/translations/en.ts @@ -1363,6 +1363,14 @@ const en: Record = { 'memories.confirmShareTitle': 'Share with trip members?', 'memories.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.', 'memories.confirmShareButton': 'Share photos', + 'memories.error.loadAlbums': 'Failed to load albums', + 'memories.error.linkAlbum': 'Failed to link album', + 'memories.error.unlinkAlbum': 'Failed to unlink album', + 'memories.error.syncAlbum': 'Failed to sync album', + 'memories.error.loadPhotos': 'Failed to load photos', + 'memories.error.addPhotos': 'Failed to add photos', + 'memories.error.removePhoto': 'Failed to remove photo', + 'memories.error.toggleSharing': 'Failed to update sharing', // Collab Addon 'collab.tabs.chat': 'Chat', diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts index b2a7807..2fc2b53 100644 --- a/server/src/middleware/auth.ts +++ b/server/src/middleware/auth.ts @@ -16,7 +16,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void => const token = extractToken(req); if (!token) { - res.status(401).json({ error: 'Access token required' }); + res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' }); return; } @@ -26,13 +26,13 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void => 'SELECT id, username, email, role FROM users WHERE id = ?' ).get(decoded.id) as User | undefined; if (!user) { - res.status(401).json({ error: 'User not found' }); + res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' }); return; } (req as AuthRequest).user = user; next(); } catch (err: unknown) { - res.status(401).json({ error: 'Invalid or expired token' }); + res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' }); } }; diff --git a/server/src/routes/immich.ts b/server/src/routes/immich.ts index c39a7ec..9b63da4 100644 --- a/server/src/routes/immich.ts +++ b/server/src/routes/immich.ts @@ -359,12 +359,12 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res: // List user's Immich albums router.get('/albums', authenticate, async (req: Request, res: Response) => { const authReq = req as AuthRequest; - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { - const resp = await fetch(`${user.immich_url}/api/albums`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/albums`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' }); @@ -431,12 +431,12 @@ router.post('/trips/:tripId/album-links/:linkId/sync', authenticate, async (req: .get(linkId, tripId, authReq.user.id) as any; if (!link) return res.status(404).json({ error: 'Album link not found' }); - const user = db.prepare('SELECT immich_url, immich_api_key FROM users WHERE id = ?').get(authReq.user.id) as any; - if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' }); + const creds = getImmichCredentials(authReq.user.id); + if (!creds) return res.status(400).json({ error: 'Immich not configured' }); try { - const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, { - headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' }, + const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, { + headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' }, signal: AbortSignal.timeout(15000), }); if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });