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:
@@ -25,7 +25,7 @@ apiClient.interceptors.request.use(
|
|||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(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')) {
|
if (!window.location.pathname.includes('/login') && !window.location.pathname.includes('/register')) {
|
||||||
window.location.href = '/login'
|
window.location.href = '/login'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import apiClient from '../../api/client'
|
|||||||
import { useAuthStore } from '../../store/authStore'
|
import { useAuthStore } from '../../store/authStore'
|
||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { getAuthUrl } from '../../api/authUrl'
|
import { getAuthUrl } from '../../api/authUrl'
|
||||||
|
import { useToast } from '../shared/Toast'
|
||||||
|
|
||||||
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
function ImmichImg({ baseUrl, style, loading }: { baseUrl: string; style?: React.CSSProperties; loading?: 'lazy' | 'eager' }) {
|
||||||
const [src, setSrc] = useState('')
|
const [src, setSrc] = useState('')
|
||||||
@@ -40,6 +41,7 @@ interface MemoriesPanelProps {
|
|||||||
|
|
||||||
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPanelProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const toast = useToast()
|
||||||
const currentUser = useAuthStore(s => s.user)
|
const currentUser = useAuthStore(s => s.user)
|
||||||
|
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
@@ -81,7 +83,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
try {
|
try {
|
||||||
const res = await apiClient.get('/integrations/immich/albums')
|
const res = await apiClient.get('/integrations/immich/albums')
|
||||||
setAlbums(res.data.albums || [])
|
setAlbums(res.data.albums || [])
|
||||||
} catch { setAlbums([]) }
|
} catch { setAlbums([]); toast.error(t('memories.error.loadAlbums')) }
|
||||||
finally { setAlbumsLoading(false) }
|
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 linksRes = await apiClient.get(`/integrations/immich/trips/${tripId}/album-links`)
|
||||||
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
const newLink = (linksRes.data.links || []).find((l: any) => l.immich_album_id === albumId)
|
||||||
if (newLink) await syncAlbum(newLink.id)
|
if (newLink) await syncAlbum(newLink.id)
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.linkAlbum')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const unlinkAlbum = async (linkId: number) => {
|
const unlinkAlbum = async (linkId: number) => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/album-links/${linkId}`)
|
||||||
loadAlbumLinks()
|
loadAlbumLinks()
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.unlinkAlbum')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncAlbum = async (linkId: number) => {
|
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 apiClient.post(`/integrations/immich/trips/${tripId}/album-links/${linkId}/sync`)
|
||||||
await loadAlbumLinks()
|
await loadAlbumLinks()
|
||||||
await loadPhotos()
|
await loadPhotos()
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.syncAlbum')) }
|
||||||
finally { setSyncing(null) }
|
finally { setSyncing(null) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +180,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setPickerPhotos(res.data.assets || [])
|
setPickerPhotos(res.data.assets || [])
|
||||||
} catch {
|
} catch {
|
||||||
setPickerPhotos([])
|
setPickerPhotos([])
|
||||||
|
toast.error(t('memories.error.loadPhotos'))
|
||||||
} finally {
|
} finally {
|
||||||
setPickerLoading(false)
|
setPickerLoading(false)
|
||||||
}
|
}
|
||||||
@@ -206,7 +209,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
})
|
})
|
||||||
setShowPicker(false)
|
setShowPicker(false)
|
||||||
loadInitial()
|
loadInitial()
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.addPhotos')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Remove photo ──────────────────────────────────────────────────────────
|
// ── Remove photo ──────────────────────────────────────────────────────────
|
||||||
@@ -215,7 +218,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
try {
|
try {
|
||||||
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
await apiClient.delete(`/integrations/immich/trips/${tripId}/photos/${assetId}`)
|
||||||
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
setTripPhotos(prev => prev.filter(p => p.immich_asset_id !== assetId))
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.removePhoto')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Toggle sharing ────────────────────────────────────────────────────────
|
// ── Toggle sharing ────────────────────────────────────────────────────────
|
||||||
@@ -226,7 +229,7 @@ export default function MemoriesPanel({ tripId, startDate, endDate }: MemoriesPa
|
|||||||
setTripPhotos(prev => prev.map(p =>
|
setTripPhotos(prev => prev.map(p =>
|
||||||
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
p.immich_asset_id === assetId ? { ...p, shared: shared ? 1 : 0 } : p
|
||||||
))
|
))
|
||||||
} catch {}
|
} catch { toast.error(t('memories.error.toggleSharing')) }
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1363,6 +1363,14 @@ const en: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
'memories.confirmShareTitle': 'Share with trip members?',
|
'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.confirmShareHint': '{count} photos will be visible to all members of this trip. You can make individual photos private later.',
|
||||||
'memories.confirmShareButton': 'Share photos',
|
'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 Addon
|
||||||
'collab.tabs.chat': 'Chat',
|
'collab.tabs.chat': 'Chat',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
|
|||||||
const token = extractToken(req);
|
const token = extractToken(req);
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
res.status(401).json({ error: 'Access token required' });
|
res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,13 +26,13 @@ const authenticate = (req: Request, res: Response, next: NextFunction): void =>
|
|||||||
'SELECT id, username, email, role FROM users WHERE id = ?'
|
'SELECT id, username, email, role FROM users WHERE id = ?'
|
||||||
).get(decoded.id) as User | undefined;
|
).get(decoded.id) as User | undefined;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
res.status(401).json({ error: 'User not found' });
|
res.status(401).json({ error: 'User not found', code: 'AUTH_REQUIRED' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(req as AuthRequest).user = user;
|
(req as AuthRequest).user = user;
|
||||||
next();
|
next();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
res.status(401).json({ error: 'Invalid or expired token' });
|
res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -359,12 +359,12 @@ router.get('/assets/:assetId/original', authFromQuery, async (req: Request, res:
|
|||||||
// List user's Immich albums
|
// List user's Immich albums
|
||||||
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
router.get('/albums', authenticate, async (req: Request, res: Response) => {
|
||||||
const authReq = req as AuthRequest;
|
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;
|
const creds = getImmichCredentials(authReq.user.id);
|
||||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${user.immich_url}/api/albums`, {
|
const resp = await fetch(`${creds.immich_url}/api/albums`, {
|
||||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(10000),
|
signal: AbortSignal.timeout(10000),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch albums' });
|
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;
|
.get(linkId, tripId, authReq.user.id) as any;
|
||||||
if (!link) return res.status(404).json({ error: 'Album link not found' });
|
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;
|
const creds = getImmichCredentials(authReq.user.id);
|
||||||
if (!user?.immich_url || !user?.immich_api_key) return res.status(400).json({ error: 'Immich not configured' });
|
if (!creds) return res.status(400).json({ error: 'Immich not configured' });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(`${user.immich_url}/api/albums/${link.immich_album_id}`, {
|
const resp = await fetch(`${creds.immich_url}/api/albums/${link.immich_album_id}`, {
|
||||||
headers: { 'x-api-key': user.immich_api_key, 'Accept': 'application/json' },
|
headers: { 'x-api-key': creds.immich_api_key, 'Accept': 'application/json' },
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });
|
if (!resp.ok) return res.status(resp.status).json({ error: 'Failed to fetch album' });
|
||||||
|
|||||||
Reference in New Issue
Block a user