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 <noreply@anthropic.com>
This commit is contained in:
jubnl
2026-04-01 21:19:53 +02:00
parent fabf5a7e26
commit 1a4c04e239
5 changed files with 30 additions and 19 deletions

View File

@@ -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'
}

View File

@@ -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 ───────────────────────────────────────────────────────────────

View File

@@ -1363,6 +1363,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
'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',

View File

@@ -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' });
}
};

View File

@@ -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' });