tests
This commit is contained in:
@@ -146,14 +146,14 @@
|
||||
padding: 0.25em;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: oklch(0.22 0.06 260deg);
|
||||
color: var(--on-accent);
|
||||
background: var(--preview-nav-bg);
|
||||
color: var(--preview-nav-fg);
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
||||
background: oklch(0.18 0.06 260deg);
|
||||
background: var(--preview-nav-bg-hover);
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:disabled {
|
||||
@@ -162,7 +162,7 @@
|
||||
}
|
||||
|
||||
.skeuo-card__nav-button:focus-visible {
|
||||
outline: 2px solid var(--accent, #2684ff);
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
+4
-6356
File diff suppressed because it is too large
Load Diff
@@ -1390,7 +1390,7 @@ button.danger:hover:not([disabled]) {
|
||||
gap: 0.4rem;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--danger, #d14343);
|
||||
color: var(--danger);
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.4rem 0.5rem;
|
||||
@@ -2786,7 +2786,7 @@ form.inline {
|
||||
}
|
||||
|
||||
.settings-form__error {
|
||||
color: var(--danger, #d14343);
|
||||
color: var(--danger);
|
||||
font-size: 0.85rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user