From 6f6abef5d2700d770c4dfe2736f84e7a319b7acc Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Sat, 11 Oct 2025 13:36:27 +0200 Subject: [PATCH] logout --- backend/src/routes/auth.rs | 22 +++++- frontend/src/index.jsx | 137 +++++++++++++++++++++++++++++++------ 2 files changed, 134 insertions(+), 25 deletions(-) diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs index 90f3c28..04b19cc 100644 --- a/backend/src/routes/auth.rs +++ b/backend/src/routes/auth.rs @@ -171,11 +171,13 @@ pub async fn logout( jar: Option>, ) -> AppResult<(HeaderMap, StatusCode)> { let mut conn = state.db()?; + let now = Utc::now().naive_utc(); + let mut rows_affected = 0; + if let Some(cookies) = jar { if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) { let hashed = hash_refresh_token(value); - let now = Utc::now().naive_utc(); - let _ = diesel::update( + rows_affected = diesel::update( refresh_dsl::refresh_tokens .filter(refresh_dsl::token_hash.eq(hashed)) .filter(refresh_dsl::user_id.eq(user.user_id)) @@ -185,10 +187,24 @@ pub async fn logout( refresh_dsl::revoked_at.eq(now), refresh_dsl::updated_at.eq(now), )) - .execute(&mut conn); + .execute(&mut conn) + .unwrap_or(0); } } + if rows_affected == 0 { + let _ = diesel::update( + refresh_dsl::refresh_tokens + .filter(refresh_dsl::user_id.eq(user.user_id)) + .filter(refresh_dsl::revoked_at.is_null()), + ) + .set(( + refresh_dsl::revoked_at.eq(now), + refresh_dsl::updated_at.eq(now), + )) + .execute(&mut conn); + } + let mut headers = HeaderMap::new(); headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state)); Ok((headers, StatusCode::NO_CONTENT)) diff --git a/frontend/src/index.jsx b/frontend/src/index.jsx index 239685d..3d201b6 100644 --- a/frontend/src/index.jsx +++ b/frontend/src/index.jsx @@ -1149,6 +1149,9 @@ const Sidebar = ({ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { const [token, setToken] = useState(() => window.localStorage.getItem('paperless_token') || ''); const [status, setStatus] = useState(null); + const setStatusMessage = useCallback((message, variant = 'info') => { + setStatus(message ? { message, variant } : null); + }, []); const [loading, setLoading] = useState(false); const [folderNodes, setFolderNodes] = useState(() => { const rootNode = createRootNode(); @@ -1165,6 +1168,23 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { const [focusedDocumentId, setFocusedDocumentId] = useState(routeDocumentId); const [documentDetails, setDocumentDetails] = useState(() => new Map()); const documentDetailsRef = useRef(documentDetails); + const tokenRef = useRef(token); + const refreshPromiseRef = useRef(null); + const refreshAccessToken = useCallback(async () => { + console.log('[Auth] Attempting to refresh access token…'); + try { + const { data } = await api.post('/auth/refresh'); + if (data?.access_token) { + setToken(data.access_token); + console.log('[Auth] Access token refreshed'); + return data.access_token; + } + throw new Error('Missing access token in refresh response'); + } catch (error) { + console.warn('[Auth] Failed to refresh access token', error); + throw error; + } + }, []); const [searchResults, setSearchResults] = useState(null); const [tags, setTags] = useState([]); const [searchQuery, setSearchQuery] = useState(''); @@ -1219,6 +1239,10 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { documentDetailsRef.current = documentDetails; }, [documentDetails]); + useEffect(() => { + tokenRef.current = token; + }, [token]); + useEffect(() => { if (token) { api.defaults.headers.common.Authorization = `Bearer ${token}`; @@ -1229,9 +1253,70 @@ function App({ routeFolderId = null, routeDocumentId = null, navigate }) { } }, [token]); - const setStatusMessage = useCallback((message, variant = 'info') => { - setStatus(message ? { message, variant } : null); - }, []); + useEffect(() => { + const requestInterceptor = api.interceptors.request.use((config) => { + const currentToken = tokenRef.current; + if (currentToken) { + config.headers = config.headers || {}; + if (!config.headers.Authorization) { + config.headers.Authorization = `Bearer ${currentToken}`; + } + } + return config; + }); + + const responseInterceptor = api.interceptors.response.use( + (response) => response, + async (error) => { + const { response, config } = error; + if (!response || !config) { + return Promise.reject(error); + } + + const status = response.status; + const url = typeof config.url === 'string' ? config.url : ''; + const isAuthRoute = url.includes('/auth/login') || url.includes('/auth/refresh'); + + if (status === 401 && !config._retry && !isAuthRoute) { + console.warn('[Auth] 401 received for', url, '- attempting token refresh'); + + if (!refreshPromiseRef.current) { + refreshPromiseRef.current = (async () => { + try { + return await refreshAccessToken(); + } finally { + refreshPromiseRef.current = null; + } + })(); + } + + try { + const newToken = await refreshPromiseRef.current; + if (!newToken) { + throw new Error('No token returned from refresh'); + } + config._retry = true; + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${newToken}`; + console.log('[Auth] Retrying original request', url); + return api(config); + } catch (refreshError) { + console.warn('[Auth] Refresh failed, clearing session'); + setToken(''); + setStatusMessage('Session expired. Please log in again.', 'error'); + return Promise.reject(refreshError); + } + } + + return Promise.reject(error); + }, + ); + + return () => { + api.interceptors.request.eject(requestInterceptor); + api.interceptors.response.eject(responseInterceptor); + }; + }, [refreshAccessToken, setStatusMessage]); useEffect(() => { if (!selectedDocumentIds.length) { @@ -2877,25 +2962,33 @@ const handleDownload = useCallback( [setStatusMessage], ); - const handleLogout = useCallback(() => { - setToken(''); - setStatusMessage('Logged out.', 'info'); - setFolderNodes(new Map([[createRootNode().id, createRootNode()]])); - setFolderContents(new Map()); - setSelectedFolder('root'); - setCurrentFolder(null); - setCurrentSubfolders([]); - setDocuments([]); - setSelectedDocumentIds([]); - setFocusedDocumentId(null); - selectionAnchorRef.current = null; - setDraggedDocumentIds([]); - setDocumentDetails(new Map()); - setSearchResults(null); - setTags([]); - setSearchQuery(''); - setActiveTagFilters([]); - initializedRef.current = false; + const handleLogout = useCallback(async () => { + try { + setLoading(true); + await api.post('/auth/logout'); + } catch (error) { + console.warn('[Auth] Failed to revoke refresh token during logout', error); + } finally { + setLoading(false); + setToken(''); + setStatusMessage('Logged out.', 'info'); + setFolderNodes(new Map([[createRootNode().id, createRootNode()]])); + setFolderContents(new Map()); + setSelectedFolder('root'); + setCurrentFolder(null); + setCurrentSubfolders([]); + setDocuments([]); + setSelectedDocumentIds([]); + setFocusedDocumentId(null); + selectionAnchorRef.current = null; + setDraggedDocumentIds([]); + setDocumentDetails(new Map()); + setSearchResults(null); + setTags([]); + setSearchQuery(''); + setActiveTagFilters([]); + initializedRef.current = false; + } }, [setStatusMessage]); const handleBulkReanalyze = useCallback(async () => {