This commit is contained in:
2025-10-31 01:26:24 +01:00
parent fa43bc749e
commit 722c2f9a5c
14 changed files with 6497 additions and 6377 deletions
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import AppLayout from './AppLayout';
import DocumentsRoute from './DocumentsRoute';
import LoginRoute from './LoginRoute';
const AppRouter = () => (
<Routes>
<Route path="/account/login" element={<LoginRoute />} />
<Route element={<AppLayout />}>
<Route path="/" element={<Navigate to="/documents" replace />} />
<Route path="/documents" element={<DocumentsRoute />} />
<Route path="/documents/folder/:folderId" element={<DocumentsRoute />} />
<Route path="/documents/:documentId" element={<DocumentsRoute />} />
<Route path="*" element={<Navigate to="/documents" replace />} />
</Route>
</Routes>
);
export default AppRouter;
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import Sidebar from '../sidebar/Sidebar';
const DocumentsLayout = ({ sidebarProps, children, sidebarCollapsed }) => (
<main className={`documents-main${sidebarCollapsed ? ' documents-main--sidebar-collapsed' : ''}`}>
{!sidebarCollapsed ? <Sidebar {...sidebarProps} /> : null}
{children}
</main>
);
export default DocumentsLayout;
+204
View File
@@ -0,0 +1,204 @@
import React, { useCallback, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ChevronsRightIcon } from '../ui/icons';
import { createDocumentsSurface } from '../documents/DocumentsTable';
import { createPreviewSurface } from '../preview/PreviewWorkspace';
import { createDesktopSurface } from '../DesktopWorkspace';
import { useAppShell } from '../appShellContext';
import DocumentsLayout from './DocumentsLayout';
const DocumentsRoute = () => {
const {
sidebarProps,
documentsTableProps,
detailPanelProps,
detailPanelOpen,
workspaceMode,
skeuoWorkspaceProps,
openTagsModal,
openCorrespondentsModal,
openDocumentTypesModal,
previewWorkspaceDocument,
previewWorkspaceEntry,
closeDocumentPreview,
handleThumbnailRegeneration,
ensurePreviewData,
resolveApiPath,
ensureAssetUrl,
getDocumentAsset,
notifyApiError,
} = useAppShell();
const navigate = useNavigate();
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const collapseSidebar = useCallback(() => setSidebarCollapsed(true), []);
const expandSidebar = useCallback(() => setSidebarCollapsed(false), []);
const sidebarPropsWithActions = useMemo(
() => ({
...sidebarProps,
onManageTags: openTagsModal,
onManageCorrespondents: openCorrespondentsModal,
onManageDocumentTypes: openDocumentTypesModal,
onCollapse: collapseSidebar,
}),
[sidebarProps, openTagsModal, openCorrespondentsModal, openDocumentTypesModal, collapseSidebar],
);
const breadcrumbs = documentsTableProps?.breadcrumbs || null;
const parentBreadcrumb = useMemo(() => {
if (!Array.isArray(breadcrumbs) || breadcrumbs.length <= 1) {
return null;
}
return breadcrumbs[breadcrumbs.length - 2];
}, [breadcrumbs]);
const handleNavigateParent = useCallback(() => {
if (!parentBreadcrumb) {
return;
}
const target = parentBreadcrumb.id === 'root'
? '/documents'
: `/documents/folder/${parentBreadcrumb.id}`;
navigate(target);
}, [navigate, parentBreadcrumb]);
const isWorkspace = workspaceMode === 'skeuo';
const isPreviewWorkspace = Boolean(previewWorkspaceDocument);
const showPreviewWorkspace = !isWorkspace && isPreviewWorkspace;
const renderSidebarToggle = useCallback(() => {
if (!sidebarCollapsed) {
return null;
}
return (
<button
type="button"
className="icon-button"
onClick={expandSidebar}
aria-label="Expand sidebar"
title="Expand sidebar"
>
<ChevronsRightIcon />
</button>
);
}, [sidebarCollapsed, expandSidebar]);
const documentsSurface = useMemo(() => {
if (!documentsTableProps) {
return null;
}
return createDocumentsSurface({
tableProps: documentsTableProps,
parentBreadcrumb,
onNavigateParent: parentBreadcrumb ? handleNavigateParent : null,
renderSidebarToggle,
detailProps: detailPanelProps,
detailOpen: detailPanelOpen,
});
}, [
documentsTableProps,
parentBreadcrumb,
handleNavigateParent,
renderSidebarToggle,
detailPanelProps,
detailPanelOpen,
]);
const previewSurface = useMemo(() => {
if (!showPreviewWorkspace || !previewWorkspaceDocument) {
return null;
}
return createPreviewSurface({
document: previewWorkspaceDocument,
previewEntry: previewWorkspaceEntry,
ensureAssetUrl,
ensurePreviewData,
getDocumentAsset,
resolveApiPath,
notifyApiError,
onRegenerate: handleThumbnailRegeneration,
onClose: closeDocumentPreview,
renderSidebarToggle,
});
}, [
showPreviewWorkspace,
previewWorkspaceDocument,
previewWorkspaceEntry,
ensureAssetUrl,
ensurePreviewData,
getDocumentAsset,
resolveApiPath,
notifyApiError,
handleThumbnailRegeneration,
closeDocumentPreview,
renderSidebarToggle,
]);
const workspaceSurface = useMemo(() => {
if (!isWorkspace) {
return null;
}
return createDesktopSurface({
workspaceProps: skeuoWorkspaceProps,
renderSidebarToggle,
});
}, [isWorkspace, skeuoWorkspaceProps, renderSidebarToggle]);
const surface = showPreviewWorkspace
? previewSurface
: isWorkspace
? workspaceSurface
: documentsSurface;
if (!surface) {
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className="main-content main-content--documents">
<div className="main-content__body main-content__body--documents" />
</div>
</DocumentsLayout>
);
}
const variant = surface.variant || 'documents';
const mainContentClass = `main-content main-content--${variant}${
surface.detail ? ' main-content--has-detail' : ''
}`;
const bodyClass = `main-content__body main-content__body--${variant}${
surface.detail ? ' main-content__body--has-detail' : ''
}`;
const header = surface.header || null;
return (
<DocumentsLayout
sidebarProps={sidebarPropsWithActions}
sidebarCollapsed={sidebarCollapsed}
>
<div className={mainContentClass}>
{header ? (
<div className="panel-header main-content__header">
{header.leading}
<h2 className="main-content__title">
{header.title}
{header.subtitle ? (
<span className="main-content__subtitle">{header.subtitle}</span>
) : null}
</h2>
<div className="panel-actions__spacer" />
{header.actions}
</div>
) : null}
<div className={bodyClass}>{surface.content}</div>
{surface.detail || null}
</div>
</DocumentsLayout>
);
};
export default DocumentsRoute;
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
const DropOverlay = ({ active, folderName }) => (
<div className={`drop-overlay${active ? ' active' : ''}`}>
<div className="drop-overlay__content">
Drop files to upload to <strong>{folderName || 'this location'}</strong>
</div>
</div>
);
export default DropOverlay;
+278
View File
@@ -0,0 +1,278 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import LoginView from '../login/LoginView';
import useApiError from '../hooks/useApiError';
import {
isWebAuthnAvailable,
preparePublicKeyRequestOptions,
preparePublicKeyCreationOptions,
serializeAuthenticationCredential,
serializeRegistrationCredential,
} from '../utils/webauthn';
import { api, useAppDispatch, useAppState } from './appState';
const LoginRoute = () => {
const { status: appStatus, tenantSelection } = useAppState();
const appDispatch = useAppDispatch();
const location = useLocation();
const [status, setStatus] = useState(null);
const [selectingTenantId, setSelectingTenantId] = useState(null);
const passkeySupported = isWebAuthnAvailable();
const [passkeyLoading, setPasskeyLoading] = useState(false);
const signupSupported = passkeySupported;
const [signupLoading, setSignupLoading] = useState(false);
const setStatusMessage = useCallback((message, variant = 'info') => {
setStatus(message ? { message, variant } : null);
}, []);
const handleLoginApiReport = useCallback(
({ message, variant }) => setStatusMessage(message, variant),
[setStatusMessage],
);
const reportLoginError = useApiError({
onReport: handleLoginApiReport,
});
const notifyLoginError = useCallback(
(error, fallbackMessage, variant = 'error') =>
reportLoginError(error, { message: fallbackMessage, variant }),
[reportLoginError],
);
const handleTenantSelect = useCallback(
async (tenant) => {
if (!tenantSelection?.selectionToken || !tenant?.id) {
return;
}
try {
setSelectingTenantId(tenant.id);
const { data } = await api.post(
'/auth/select-tenant',
{ tenant_id: tenant.id },
{
headers: {
Authorization: `Bearer ${tenantSelection.selectionToken}`,
},
},
);
if (!data?.access_token) {
throw new Error('Invalid tenant selection response.');
}
appDispatch({
type: 'LOGIN_SUCCESS',
token: data.access_token,
tenant: data.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
const message = error?.response?.data?.error || 'Failed to finalize login.';
notifyLoginError(error, message, 'error');
} finally {
setSelectingTenantId(null);
}
},
[appDispatch, notifyLoginError, setStatusMessage, tenantSelection],
);
const handleCancelSelection = useCallback(() => {
appDispatch({ type: 'CLEAR_TENANT_SELECTION' });
setStatusMessage(null);
}, [appDispatch, setStatusMessage]);
const handlePasskeyLogin = useCallback(
async (rawUsername) => {
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
if (!username) {
setStatusMessage('Enter your username before using a passkey.', 'error');
return;
}
if (!passkeySupported) {
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return;
}
setPasskeyLoading(true);
appDispatch({ type: 'LOGIN_REQUEST' });
try {
const { data: startData } = await api.post('/auth/passkeys/login/start', { username });
const challengeId = startData.challengeId;
const publicKeyOptions = startData.publicKey;
if (!challengeId || !publicKeyOptions) {
throw new Error('Invalid passkey challenge response.');
}
const publicKey = preparePublicKeyRequestOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to continue.', 'info');
const assertion = await navigator.credentials.get({ publicKey });
if (!assertion) {
setStatusMessage('Passkey login cancelled.', 'info');
return;
}
const serialized = serializeAuthenticationCredential(assertion);
const finishPayload = {
challengeId,
credential: serialized,
};
const { data: finishData } = await api.post('/auth/passkeys/login/finish', finishPayload);
if (finishData?.access_token && Array.isArray(finishData?.tenants)) {
appDispatch({
type: 'TENANT_SELECTION_REQUIRED',
selectionToken: finishData.access_token,
tenants: finishData.tenants,
});
setStatusMessage('Select a tenant to continue.', 'info');
return;
}
if (!finishData?.access_token) {
throw new Error('Invalid login response.');
}
appDispatch({
type: 'LOGIN_SUCCESS',
token: finishData.access_token,
tenant: finishData.tenant || null,
});
setStatusMessage('Login successful.', 'success');
} catch (error) {
if (error?.name === 'NotAllowedError') {
setStatusMessage('Passkey login cancelled.', 'info');
} else if (error?.response?.status === 404) {
setStatusMessage('No passkey registered for this username. Create an account first.', 'error');
appDispatch({ type: 'LOGIN_FAILURE', error: 'passkey not registered' });
} else if (error?.response?.status === 400 && error?.response?.data?.error) {
setStatusMessage(error.response.data.error, 'error');
appDispatch({ type: 'LOGIN_FAILURE', error: error.response.data.error });
} else {
const message = error?.response?.data?.error || error.message || 'Passkey login failed.';
notifyLoginError(error, message);
appDispatch({ type: 'LOGIN_FAILURE', error: message });
}
} finally {
setPasskeyLoading(false);
}
},
[appDispatch, notifyLoginError, passkeySupported, setStatusMessage],
);
const handleSignup = useCallback(
async (rawUsername) => {
const username = typeof rawUsername === 'string' ? rawUsername.trim() : '';
if (!username) {
setStatusMessage('Choose a username to create your account.', 'error');
return;
}
if (!passkeySupported) {
setStatusMessage('Passkeys are not supported in this browser.', 'error');
return;
}
setSignupLoading(true);
try {
const { data: startData } = await api.post('/auth/signup/start', { username });
const signupToken = startData.signup_token;
const challengePayload = startData.challenge;
const challengeId = challengePayload?.challengeId;
const publicKeyOptions = challengePayload?.publicKey;
if (!signupToken || !challengeId || !publicKeyOptions) {
throw new Error('Invalid signup challenge response.');
}
const publicKey = preparePublicKeyCreationOptions({ publicKey: publicKeyOptions });
setStatusMessage('Confirm the passkey prompt to finish creating your account.', 'info');
const credential = await navigator.credentials.create({ publicKey });
if (!credential) {
setStatusMessage('Signup cancelled.', 'info');
return;
}
const serialized = serializeRegistrationCredential(credential);
const finishPayload = {
signup_token: signupToken,
credential: serialized,
};
const { data: finishData } = await api.post('/auth/signup/finish', finishPayload);
if (finishData?.access_token && Array.isArray(finishData?.tenants)) {
appDispatch({
type: 'TENANT_SELECTION_REQUIRED',
selectionToken: finishData.access_token,
tenants: finishData.tenants,
});
setStatusMessage('Select a tenant to continue.', 'info');
return;
}
if (!finishData?.access_token) {
throw new Error('Invalid signup response.');
}
appDispatch({
type: 'LOGIN_SUCCESS',
token: finishData.access_token,
tenant: finishData.tenant || null,
});
setStatusMessage('Account created. Welcome!', 'success');
} catch (error) {
if (error?.name === 'NotAllowedError') {
setStatusMessage('Signup cancelled.', 'info');
} else if (error?.response?.status === 409) {
setStatusMessage('This passkey is already registered. Try signing in instead.', 'error');
} else if (error?.response?.data?.error) {
setStatusMessage(error.response.data.error, 'error');
} else {
const message = error?.message || 'Failed to create account.';
setStatusMessage(message, 'error');
}
} finally {
setSignupLoading(false);
}
},
[appDispatch, passkeySupported, setStatusMessage],
);
const redirectTarget = useMemo(() => {
const target = location.state?.from;
if (typeof target === 'string' && target.startsWith('/')) {
return target;
}
return '/documents';
}, [location.state]);
if (!['logged-out', 'authenticating', 'selecting-tenant'].includes(appStatus)) {
return <Navigate to={redirectTarget} replace />;
}
return (
<div className="app-shell">
<LoginView
status={status}
tenantSelection={tenantSelection}
onSelectTenant={handleTenantSelect}
onCancelSelection={handleCancelSelection}
selectingTenantId={selectingTenantId}
onPasskeyLogin={handlePasskeyLogin}
passkeySupported={passkeySupported}
passkeyLoading={passkeyLoading}
onSignup={handleSignup}
signupSupported={signupSupported}
signupLoading={signupLoading}
/>
</div>
);
};
export default LoginRoute;
+240
View File
@@ -0,0 +1,240 @@
import React, { useContext, useEffect, useMemo, useReducer } from 'react';
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
withCredentials: true,
});
const storage = typeof window !== 'undefined' ? window.sessionStorage : undefined;
const STORED_TOKEN = storage?.getItem('papercrate_token') ?? '';
let STORED_TENANT = null;
if (storage) {
try {
const rawTenant = storage.getItem('papercrate_tenant');
if (rawTenant) {
STORED_TENANT = JSON.parse(rawTenant);
}
} catch (error) {
console.warn('[app] Failed to parse stored tenant metadata', error);
}
}
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,
tenantSelection: null,
tenant: STORED_TENANT,
tenants: [],
};
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,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGIN_SUCCESS':
return {
...state,
status: 'authenticated',
token: action.token,
error: null,
tenantSelection: null,
tenant: action.tenant || null,
tenants: state.tenants,
};
case 'LOGIN_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'TENANT_SELECTION_REQUIRED':
return {
status: 'selecting-tenant',
token: '',
error: null,
isRefreshing: false,
tenantSelection: {
selectionToken: action.selectionToken,
tenants: action.tenants,
},
tenant: null,
tenants: [],
};
case 'CLEAR_TENANT_SELECTION':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT_SUCCESS':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
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,
tenantSelection: null,
tenant: action.tenant || state.tenant || null,
tenants: state.tenants,
};
case 'TOKEN_REFRESH_FAILURE':
return {
status: 'logged-out',
token: '',
error: action.error || null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'LOGOUT':
return {
status: 'logged-out',
token: '',
error: null,
isRefreshing: false,
tenantSelection: null,
tenant: null,
tenants: [],
};
case 'RESET_ERROR':
return { ...state, error: null };
case 'SET_TENANTS':
return {
...state,
tenants: Array.isArray(action.tenants) ? action.tenants : [],
};
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}`;
storage?.setItem('papercrate_token', token);
} else {
delete api.defaults.headers.common.Authorization;
storage?.removeItem('papercrate_token');
}
}, [state.token]);
useEffect(() => {
if (state.tenant) {
try {
storage?.setItem('papercrate_tenant', JSON.stringify(state.tenant));
} catch (error) {
console.warn('[app] Failed to persist tenant info', error);
}
} else {
storage?.removeItem('papercrate_tenant');
}
}, [state.tenant]);
useEffect(() => {
let abort = false;
const loadTenants = async () => {
if (state.status !== 'authenticated' || !state.token) {
dispatch({ type: 'SET_TENANTS', tenants: [] });
return;
}
try {
const { data } = await api.get('/auth/tenants');
if (!abort) {
dispatch({
type: 'SET_TENANTS',
tenants: Array.isArray(data?.tenants) ? data.tenants : [],
});
}
} catch (error) {
if (!abort) {
console.warn('Failed to load tenant list', error);
}
}
};
loadTenants();
return () => {
abort = true;
};
}, [state.status, state.token, dispatch]);
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;
};
export { api, AppStateProvider, useAppState, useAppDispatch };