frontend: login route
This commit is contained in:
+271
-50
@@ -5,6 +5,7 @@ import React, {
|
|||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
useContext,
|
useContext,
|
||||||
|
useReducer,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
@@ -33,6 +34,91 @@ if (STORED_TOKEN) {
|
|||||||
api.defaults.headers.common.Authorization = `Bearer ${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 DEFAULT_FOLDER_NAME = 'All Documents';
|
||||||
|
|
||||||
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
|
const resolveApiPath = (path = '') => (API_ROOT ? `${API_ROOT}${path}` : path);
|
||||||
@@ -1495,13 +1581,15 @@ const AppShellContext = React.createContext(null);
|
|||||||
const AppLayout = () => {
|
const AppLayout = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const appState = useAppState();
|
||||||
|
const appDispatch = useAppDispatch();
|
||||||
const folderMatch = matchPath('/folders/:folderId', location.pathname);
|
const folderMatch = matchPath('/folders/:folderId', location.pathname);
|
||||||
const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname);
|
const nestedDocMatch = matchPath('/folders/:folderId/documents/:documentId', location.pathname);
|
||||||
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
|
const docMatch = nestedDocMatch || matchPath('/documents/:documentId', location.pathname);
|
||||||
const routeFolderId =
|
const routeFolderId =
|
||||||
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
|
folderMatch?.params?.folderId || nestedDocMatch?.params?.folderId || null;
|
||||||
const routeDocumentId = docMatch?.params?.documentId || 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 [status, setStatus] = useState(null);
|
||||||
const setStatusMessage = useCallback((message, variant = 'info') => {
|
const setStatusMessage = useCallback((message, variant = 'info') => {
|
||||||
setStatus(message ? { message, variant } : null);
|
setStatus(message ? { message, variant } : null);
|
||||||
@@ -1527,19 +1615,21 @@ const AppLayout = () => {
|
|||||||
const breadcrumbFetchRef = useRef(new Set());
|
const breadcrumbFetchRef = useRef(new Set());
|
||||||
const refreshAccessToken = useCallback(async () => {
|
const refreshAccessToken = useCallback(async () => {
|
||||||
console.log('[Auth] Attempting to refresh access token…');
|
console.log('[Auth] Attempting to refresh access token…');
|
||||||
|
appDispatch({ type: 'TOKEN_REFRESH_START' });
|
||||||
try {
|
try {
|
||||||
const { data } = await api.post('/auth/refresh');
|
const { data } = await api.post('/auth/refresh');
|
||||||
if (data?.access_token) {
|
if (data?.access_token) {
|
||||||
setToken(data.access_token);
|
appDispatch({ type: 'TOKEN_REFRESH_SUCCESS', token: data.access_token });
|
||||||
console.log('[Auth] Access token refreshed');
|
console.log('[Auth] Access token refreshed');
|
||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
throw new Error('Missing access token in refresh response');
|
throw new Error('Missing access token in refresh response');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[Auth] Failed to refresh access token', error);
|
console.warn('[Auth] Failed to refresh access token', error);
|
||||||
|
appDispatch({ type: 'TOKEN_REFRESH_FAILURE', error: error?.message || null });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [appDispatch]);
|
||||||
const [searchResults, setSearchResults] = useState(null);
|
const [searchResults, setSearchResults] = useState(null);
|
||||||
const [tags, setTags] = useState([]);
|
const [tags, setTags] = useState([]);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -1554,11 +1644,46 @@ const AppLayout = () => {
|
|||||||
const [previewDocumentId, setPreviewDocumentId] = useState(null);
|
const [previewDocumentId, setPreviewDocumentId] = useState(null);
|
||||||
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
|
const [previewDocumentLoading, setPreviewDocumentLoading] = useState(false);
|
||||||
|
|
||||||
const initializedRef = useRef(false);
|
const bootstrapInitializedRef = useRef(false);
|
||||||
|
const selectionInitializedRef = useRef(false);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const selectionAnchorRef = useRef(routeDocumentId);
|
const selectionAnchorRef = useRef(routeDocumentId);
|
||||||
const selectionOrderRef = useRef(initialSelection);
|
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 tagLookupById = useMemo(() => {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
tags.forEach((tag) => {
|
tags.forEach((tag) => {
|
||||||
@@ -1610,6 +1735,12 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (appStatus === 'logged-out') {
|
||||||
|
resetWorkspaceState();
|
||||||
|
}
|
||||||
|
}, [appStatus, resetWorkspaceState]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
documentDetailsRef.current = documentDetails;
|
documentDetailsRef.current = documentDetails;
|
||||||
}, [documentDetails]);
|
}, [documentDetails]);
|
||||||
@@ -1618,16 +1749,6 @@ const AppLayout = () => {
|
|||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
}, [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(() => {
|
useEffect(() => {
|
||||||
const requestInterceptor = api.interceptors.request.use((config) => {
|
const requestInterceptor = api.interceptors.request.use((config) => {
|
||||||
const currentToken = tokenRef.current;
|
const currentToken = tokenRef.current;
|
||||||
@@ -1677,7 +1798,6 @@ const AppLayout = () => {
|
|||||||
return api(config);
|
return api(config);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
console.warn('[Auth] Refresh failed, clearing session');
|
console.warn('[Auth] Refresh failed, clearing session');
|
||||||
setToken('');
|
|
||||||
setStatusMessage('Session expired. Please log in again.', 'error');
|
setStatusMessage('Session expired. Please log in again.', 'error');
|
||||||
return Promise.reject(refreshError);
|
return Promise.reject(refreshError);
|
||||||
}
|
}
|
||||||
@@ -1703,7 +1823,7 @@ const AppLayout = () => {
|
|||||||
if (!selectedDocumentIds.includes(activePreviewId)) {
|
if (!selectedDocumentIds.includes(activePreviewId)) {
|
||||||
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
setActivePreviewId(selectedDocumentIds[selectedDocumentIds.length - 1]);
|
||||||
}
|
}
|
||||||
initializedRef.current = true;
|
selectionInitializedRef.current = true;
|
||||||
}, [selectedDocumentIds, activePreviewId]);
|
}, [selectedDocumentIds, activePreviewId]);
|
||||||
|
|
||||||
const currentFolderName = useMemo(() => {
|
const currentFolderName = useMemo(() => {
|
||||||
@@ -1730,7 +1850,7 @@ const AppLayout = () => {
|
|||||||
let nextSelection = [];
|
let nextSelection = [];
|
||||||
|
|
||||||
setSelectedDocumentIds((previous) => {
|
setSelectedDocumentIds((previous) => {
|
||||||
if (initializedRef.current) {
|
if (selectionInitializedRef.current) {
|
||||||
nextSelection = previous.filter((id) => availableIds.has(id));
|
nextSelection = previous.filter((id) => availableIds.has(id));
|
||||||
return nextSelection;
|
return nextSelection;
|
||||||
}
|
}
|
||||||
@@ -2301,12 +2421,20 @@ const AppLayout = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
setStatusMessage('Failed to initialize data.', 'error');
|
setStatusMessage('Failed to initialize data.', 'error');
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [refreshTags, routeFolderId, loadFolder, setStatusMessage]);
|
}, [refreshTags, routeFolderId, loadFolder, setStatusMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (!token) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (appStatus !== 'ready' && appStatus !== 'bootstrapping') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const targetParam = routeFolderId ?? 'root';
|
const targetParam = routeFolderId ?? 'root';
|
||||||
|
|
||||||
if (targetParam === 'root' && routeDocumentId) {
|
if (targetParam === 'root' && routeDocumentId) {
|
||||||
@@ -2317,7 +2445,15 @@ const AppLayout = () => {
|
|||||||
if (targetParam !== selectedFolder || !hasData) {
|
if (targetParam !== selectedFolder || !hasData) {
|
||||||
loadFolder(targetParam, { showLoading: !routeDocumentId });
|
loadFolder(targetParam, { showLoading: !routeDocumentId });
|
||||||
}
|
}
|
||||||
}, [routeFolderId, routeDocumentId, selectedFolder, loadFolder, folderContents]);
|
}, [
|
||||||
|
token,
|
||||||
|
appStatus,
|
||||||
|
routeFolderId,
|
||||||
|
routeDocumentId,
|
||||||
|
selectedFolder,
|
||||||
|
loadFolder,
|
||||||
|
folderContents,
|
||||||
|
]);
|
||||||
|
|
||||||
const refreshCurrentFolder = useCallback(async () => {
|
const refreshCurrentFolder = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -2336,10 +2472,39 @@ const AppLayout = () => {
|
|||||||
}, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]);
|
}, [selectedFolder, ensureFolderData, applySelectedFolder, ensureDocumentDetail, setStatusMessage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token || initializedRef.current) return;
|
if (appStatus !== 'authenticated') {
|
||||||
initializedRef.current = true;
|
return;
|
||||||
initializeAfterLogin();
|
}
|
||||||
}, [token, initializeAfterLogin]);
|
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(
|
const handleBulkMoveSubmit = useCallback(
|
||||||
async (event) => {
|
async (event) => {
|
||||||
@@ -3374,17 +3539,22 @@ const AppLayout = () => {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
appDispatch({ type: 'LOGIN_REQUEST' });
|
||||||
const { data } = await api.post('/auth/login', payload);
|
const { data } = await api.post('/auth/login', payload);
|
||||||
setToken(data.access_token);
|
appDispatch({ type: 'LOGIN_SUCCESS', token: data.access_token });
|
||||||
setStatusMessage('Login successful.', 'success');
|
setStatusMessage('Login successful.', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
appDispatch({
|
||||||
|
type: 'LOGIN_FAILURE',
|
||||||
|
error: error?.response?.data?.error || 'Login failed. Check credentials.',
|
||||||
|
});
|
||||||
setStatusMessage('Login failed. Check credentials.', 'error');
|
setStatusMessage('Login failed. Check credentials.', 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setStatusMessage],
|
[appDispatch, setStatusMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleLogout = useCallback(async () => {
|
const handleLogout = useCallback(async () => {
|
||||||
@@ -3395,26 +3565,10 @@ const AppLayout = () => {
|
|||||||
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
console.warn('[Auth] Failed to revoke refresh token during logout', error);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setToken('');
|
appDispatch({ type: 'LOGOUT' });
|
||||||
setStatusMessage('Logged out.', 'info');
|
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 () => {
|
const handleBulkReanalyze = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -3845,6 +3999,7 @@ const AppLayout = () => {
|
|||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
token,
|
token,
|
||||||
|
appStatus,
|
||||||
status,
|
status,
|
||||||
dropOverlayState,
|
dropOverlayState,
|
||||||
handleBulkReanalyze,
|
handleBulkReanalyze,
|
||||||
@@ -3864,6 +4019,7 @@ const AppLayout = () => {
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
token,
|
token,
|
||||||
|
appStatus,
|
||||||
status,
|
status,
|
||||||
dropOverlayState,
|
dropOverlayState,
|
||||||
handleBulkReanalyze,
|
handleBulkReanalyze,
|
||||||
@@ -3883,11 +4039,13 @@ const AppLayout = () => {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!token) {
|
if (appStatus === 'logged-out' || appStatus === 'authenticating') {
|
||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<Navigate
|
||||||
<LoginView onSubmit={handleLogin} status={status} />
|
to="/account/login"
|
||||||
</div>
|
replace
|
||||||
|
state={{ from: location.pathname + location.search }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3903,7 +4061,9 @@ const AppLayout = () => {
|
|||||||
<div className="app-bar__meta">
|
<div className="app-bar__meta">
|
||||||
<h1>Papercrate</h1>
|
<h1>Papercrate</h1>
|
||||||
<span className="app-bar__hint">
|
<span className="app-bar__hint">
|
||||||
{previewActive
|
{appStatus === 'bootstrapping'
|
||||||
|
? 'Loading your library…'
|
||||||
|
: previewActive
|
||||||
? 'Viewing document preview. Press ← Back to return to the library.'
|
? 'Viewing document preview. Press ← Back to return to the library.'
|
||||||
: 'Drag files here to upload.'}
|
: 'Drag files here to upload.'}
|
||||||
</span>
|
</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 TagsRoute = () => {
|
||||||
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
|
const { sidebarProps, tags, refreshTags, handleTagUpdate } = useAppShell();
|
||||||
return (
|
return (
|
||||||
@@ -3987,6 +4205,7 @@ const TagsRoute = () => {
|
|||||||
|
|
||||||
const AppRouter = () => (
|
const AppRouter = () => (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
<Route path="/account/login" element={<LoginRoute />} />
|
||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route path="/" element={<Navigate to="/folders" replace />} />
|
<Route path="/" element={<Navigate to="/folders" replace />} />
|
||||||
<Route path="/folders" element={<DocumentsRoute />} />
|
<Route path="/folders" element={<DocumentsRoute />} />
|
||||||
@@ -4005,7 +4224,9 @@ const AppRouter = () => (
|
|||||||
const container = document.getElementById('app');
|
const container = document.getElementById('app');
|
||||||
const root = createRoot(container);
|
const root = createRoot(container);
|
||||||
root.render(
|
root.render(
|
||||||
<HashRouter hashType="hashbang">
|
<AppStateProvider>
|
||||||
<AppRouter />
|
<HashRouter hashType="hashbang">
|
||||||
</HashRouter>,
|
<AppRouter />
|
||||||
|
</HashRouter>
|
||||||
|
</AppStateProvider>,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user