frontend: login route
This commit is contained in:
+271
-50
@@ -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>,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user