frontend: login route
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 4m50s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 4m52s

This commit is contained in:
2025-10-12 12:23:45 +02:00
parent 4b74ee54b0
commit 6987fe1bfe
+271 -50
View File
@@ -5,6 +5,7 @@ import React, {
useRef,
useState,
useContext,
useReducer,
} from 'react';
import { createRoot } from 'react-dom/client';
import axios from 'axios';
@@ -33,6 +34,91 @@ if (STORED_TOKEN) {
api.defaults.headers.common.Authorization = `Bearer ${STORED_TOKEN}`;
}
const initialAppState = {
status: STORED_TOKEN ? 'authenticated' : 'logged-out',
token: STORED_TOKEN,
error: null,
isRefreshing: false,
};
const AppStateContext = React.createContext(null);
const AppDispatchContext = React.createContext(null);
const appStateReducer = (state, action) => {
switch (action.type) {
case 'LOGIN_REQUEST':
return { ...state, status: 'authenticating', error: null };
case 'LOGIN_SUCCESS':
return { ...state, status: 'authenticated', token: action.token, error: null };
case 'LOGIN_FAILURE':
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false };
case 'BOOTSTRAP_START':
return { ...state, status: 'bootstrapping', error: null };
case 'BOOTSTRAP_SUCCESS':
return { ...state, status: 'ready', error: null };
case 'BOOTSTRAP_FAILURE':
return { ...state, status: 'authenticated', error: action.error || null };
case 'TOKEN_REFRESH_START':
return { ...state, isRefreshing: true, error: null };
case 'TOKEN_REFRESH_SUCCESS':
return {
...state,
token: action.token,
isRefreshing: false,
status: state.status === 'logged-out' ? 'authenticated' : state.status,
};
case 'TOKEN_REFRESH_FAILURE':
return { status: 'logged-out', token: '', error: action.error || null, isRefreshing: false };
case 'LOGOUT':
return { status: 'logged-out', token: '', error: null, isRefreshing: false };
case 'RESET_ERROR':
return { ...state, error: null };
default:
return state;
}
};
const AppStateProvider = ({ children }) => {
const [state, dispatch] = useReducer(appStateReducer, initialAppState);
useEffect(() => {
const token = state.token || '';
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
window.localStorage.setItem('papercrate_token', token);
} else {
delete api.defaults.headers.common.Authorization;
window.localStorage.removeItem('papercrate_token');
}
}, [state.token]);
const stateValue = useMemo(() => state, [state]);
return (
<AppStateContext.Provider value={stateValue}>
<AppDispatchContext.Provider value={dispatch}>
{children}
</AppDispatchContext.Provider>
</AppStateContext.Provider>
);
};
const useAppState = () => {
const context = useContext(AppStateContext);
if (!context) {
throw new Error('useAppState must be used within an AppStateProvider.');
}
return context;
};
const useAppDispatch = () => {
const context = useContext(AppDispatchContext);
if (!context) {
throw new Error('useAppDispatch must be used within an AppStateProvider.');
}
return context;
};
const DEFAULT_FOLDER_NAME = 'All Documents';
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
@@ -1495,13 +1581,15 @@ const AppShellContext = React.createContext(null);
const AppLayout = () => {
const navigate = useNavigate();
const location = useLocation();
const appState = useAppState();
const appDispatch = useAppDispatch();
const folderMatch = matchPath('/folders/:folderId', location.pathname);
const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname);
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
const routeFolderId =
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
const routeDocumentId = docMatch?.params?.documentId || null;
const [token, setToken] = useState(() => window.localStorage.getItem('papercrate_token') || '');
const { status: appStatus, token } = appState;
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null);
@@ -1527,19 +1615,21 @@ const AppLayout = () => {
const breadcrumbFetchRef = useRef(new Set());
const refreshAccessToken = useCallback(async () => {
console.log('[Auth] Attempting to refresh access token…');
appDispatch({ type: 'TOKEN_REFRESH_START' });
try {
const { data } = await api.post('/auth/refresh');
if (data?.access_token) {
setToken(data.access_token);
appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: 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);
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
throw error;
}
}, []);
}, [appDispatch]);
const [searchResults, setSearchResults] = useState(null);
const [tags, setTags] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -1554,11 +1644,46 @@ const AppLayout = () => {
const [previewDocumentId, setPreviewDocumentId] = useState(null);
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
const initializedRef = useRef(false);
const bootstrapInitializedRef = useRef(false);
const selectionInitializedRef = useRef(false);
const dragCounterRef = useRef(0);
const selectionAnchorRef = useRef(routeDocumentId);
const selectionOrderRef = useRef(initialSelection);
const resetWorkspaceState = useCallback(() => {
const rootNode = createRootNode();
setFolderNodes(new Map([[rootNode.id, rootNode]]));
setFolderContents(new Map());
setSelectedFolder('root');
setCurrentFolder(null);
setCurrentSubfolders([]);
setDocuments([]);
setSelectedDocumentIds([]);
setSelectionOrder([]);
selectionOrderRef.current = [];
setFocusedDocumentId(null);
selectionAnchorRef.current = null;
setDraggedDocumentIds([]);
setDraggedFolderId(null);
setDocumentDetails(() => {
const next = new Map();
documentDetailsRef.current = next;
return next;
});
setSearchResults(null);
setTags([]);
setSearchQuery('');
setActiveTagFilters([]);
setDropOverlayState({ active: false, folderName: DEFAULT_FOLDER_NAME });
setActivePreviewId(null);
setPreviewDocumentId(null);
setPreviewDocumentLoading(false);
dragCounterRef.current = 0;
breadcrumbFetchRef.current = new Set();
bootstrapInitializedRef.current = false;
selectionInitializedRef.current = false;
}, []);
const tagLookupById = useMemo(() => {
const map = new Map();
tags.forEach((tag) => {
@@ -1610,6 +1735,12 @@ const AppLayout = () => {
}
}, []);
useEffect(() => {
if (appStatus === 'logged-out') {
resetWorkspaceState();
}
}, [appStatus, resetWorkspaceState]);
useEffect(() => {
documentDetailsRef.current = documentDetails;
}, [documentDetails]);
@@ -1618,16 +1749,6 @@ const AppLayout = () => {
tokenRef.current = token;
}, [token]);
useEffect(() => {
if (token) {
api.defaults.headers.common.Authorization = `Bearer ${token}`;
window.localStorage.setItem('papercrate_token', token);
} else {
delete api.defaults.headers.common.Authorization;
window.localStorage.removeItem('papercrate_token');
}
}, [token]);
useEffect(() => {
const requestInterceptor = api.interceptors.request.use((config) => {
const currentToken = tokenRef.current;
@@ -1677,7 +1798,6 @@ const AppLayout = () => {
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);
}
@@ -1703,7 +1823,7 @@ const AppLayout = () => {
if (!selectedDocumentIds.includes(activePreviewId)) {
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
}
initializedRef.current = true;
selectionInitializedRef.current = true;
}, [selectedDocumentIds, activePreviewId]);
const currentFolderName = useMemo(() => {
@@ -1730,7 +1850,7 @@ const AppLayout = () => {
let nextSelection = [];
setSelectedDocumentIds((previous) => {
if (initializedRef.current) {
if (selectionInitializedRef.current) {
nextSelection = previous.filter((id) => availableIds.has(id));
return nextSelection;
}
@@ -2301,12 +2421,20 @@ const AppLayout = () => {
} catch (error) {
console.error(error);
setStatusMessage('Failed to initialize data.', 'error');
throw error;
} finally {
setLoading(false);
}
}, [refreshTags, routeFolderId, loadFolder, setStatusMessage]);
useEffect(() => {
if (!token) {
return;
}
if (appStatus !== 'ready' && appStatus !== 'bootstrapping') {
return;
}
const targetParam = routeFolderId ?? 'root';
if (targetParam === 'root' && routeDocumentId) {
@@ -2317,7 +2445,15 @@ const AppLayout = () => {
if (targetParam !== selectedFolder || !hasData) {
loadFolder(targetParam, { showLoading: !routeDocumentId });
}
}, [routeFolderId, routeDocumentId, selectedFolder, loadFolder, folderContents]);
}, [
token,
appStatus,
routeFolderId,
routeDocumentId,
selectedFolder,
loadFolder,
folderContents,
]);
const refreshCurrentFolder = useCallback(async () => {
setLoading(true);
@@ -2336,10 +2472,39 @@ const AppLayout = () => {
}, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]);
useEffect(() => {
if (!token || initializedRef.current) return;
initializedRef.current = true;
initializeAfterLogin();
}, [token, initializeAfterLogin]);
if (appStatus !== 'authenticated') {
return;
}
if (bootstrapInitializedRef.current) {
return;
}
let cancelled = false;
const bootstrap = async () => {
bootstrapInitializedRef.current = true;
appDispatch({ type: 'BOOTSTRAP_START' });
try {
await initializeAfterLogin();
if (!cancelled) {
appDispatch({ type: 'BOOTSTRAP_SUCCESS' });
}
} catch (error) {
if (!cancelled) {
appDispatch({
type: 'BOOTSTRAP_FAILURE',
error: error?.message || 'Failed to initialize data.',
});
bootstrapInitializedRef.current = false;
}
}
};
bootstrap();
return () => {
cancelled = true;
};
}, [appStatus, appDispatch, initializeAfterLogin]);
const handleBulkMoveSubmit = useCallback(
async (event) => {
@@ -3374,17 +3539,22 @@ const AppLayout = () => {
}
try {
setLoading(true);
appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload);
setToken(data.access_token);
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success');
} catch (error) {
console.error(error);
appDispatch({
type: 'LOGIN_FAILURE',
error: error?.response?.data?.error || 'Login failed. Check credentials.',
});
setStatusMessage('Login failed. Check credentials.', 'error');
} finally {
setLoading(false);
}
},
[setStatusMessage],
[appDispatch, setStatusMessage],
);
const handleLogout = useCallback(async () => {
@@ -3395,26 +3565,10 @@ const AppLayout = () => {
console.warn('[Auth] Failed to revoke refresh token during logout', error);
} finally {
setLoading(false);
setToken('');
appDispatch({ type: 'LOGOUT' });
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]);
}, [appDispatch, setStatusMessage]);
const handleBulkReanalyze = useCallback(async () => {
try {
@@ -3845,6 +3999,7 @@ const AppLayout = () => {
const contextValue = useMemo(
() => ({
token,
appStatus,
status,
dropOverlayState,
handleBulkReanalyze,
@@ -3864,6 +4019,7 @@ const AppLayout = () => {
}),
[
token,
appStatus,
status,
dropOverlayState,
handleBulkReanalyze,
@@ -3883,11 +4039,13 @@ const AppLayout = () => {
],
);
if (!token) {
if (appStatus === 'logged-out' || appStatus === 'authenticating') {
return (
<div className="app-shell">
<LoginView onSubmit={handleLogin} status={status} />
</div>
<Navigate
to="/account/login"
replace
state={{ from: location.pathname + location.search }}
/>
);
}
@@ -3903,7 +4061,9 @@ const AppLayout = () => {
<div className="app-bar__meta">
<h1>Papercrate</h1>
<span className="app-bar__hint">
{previewActive
{appStatus === 'bootstrapping'
? 'Loading your library…'
: previewActive
? 'Viewing document preview. Press ← Back to return to the library.'
: 'Drag files here to upload.'}
</span>
@@ -3976,6 +4136,64 @@ const DocumentsRoute = () => {
);
};
const LoginRoute = () => {
const { status: appStatus } = useAppState();
const appDispatch = useAppDispatch();
const location = useLocation();
const [status, setStatus] = useState(null);
const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null);
}, []);
const handleLogin = useCallback(
async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const payload = {
username: form.get('username')?.toString().trim(),
password: form.get('password')?.toString() || '',
};
if (!payload.username || !payload.password) {
setStatusMessage('Username and password are required.', 'error');
return;
}
try {
appDispatch({ type: 'LOGIN_REQUEST' });
const { data } = await api.post('/auth/login', payload);
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
setStatusMessage('Login successful.', 'success');
} catch (error) {
console.error(error);
const message = error?.response?.data?.error || 'Login failed. Check credentials.';
appDispatch({ type: 'LOGIN_FAILURE', error: message });
setStatusMessage(message, 'error');
}
},
[appDispatch, setStatusMessage],
);
const redirectTarget = useMemo(() => {
const target = location.state?.from;
if (typeof target === 'string' && target.startsWith('/')) {
return target;
}
return '/folders';
}, [location.state]);
if (appStatus !== 'logged-out' && appStatus !== 'authenticating') {
return <Navigate to={redirectTarget} replace />;
}
return (
<div className="app-shell">
<LoginView onSubmit={handleLogin} status={status} />
</div>
);
};
const TagsRoute = () => {
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
return (
@@ -3987,6 +4205,7 @@ const TagsRoute = () => {
const AppRouter = () => (
<Routes>
<Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/folders" replace />} />
<Route path="/folders" element={<DocumentsRoute />} />
@@ -4005,7 +4224,9 @@ const AppRouter = () => (
const container = document.getElementById('app');
const root = createRoot(container);
root.render(
<HashRouter hashType="hashbang">
<AppRouter />
</HashRouter>,
<AppStateProvider>
<HashRouter hashType="hashbang">
<AppRouter />
</HashRouter>
</AppStateProvider>,
);