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
+4 -2
View File
@@ -53,8 +53,10 @@ struct SignupStartResponse {
#[derive(Deserialize)]
struct TenantSelectionResponse {
access_token: String,
tenants: Vec<TenantSummary>,
#[serde(rename = "access_token")]
_access_token: String,
#[serde(rename = "tenants")]
_tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)]
+56
View File
@@ -693,6 +693,62 @@ pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
Ok(collected.to_bytes().to_vec())
}
#[cfg(test)]
mod helper_tests {
use super::*;
#[tokio::test]
async fn create_session_and_login_token_provide_access() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-login";
let password = "irrelevant";
app.insert_user(username, password, "admin").await?;
let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(refresh_id, Uuid::nil(), "refresh token id should be assigned");
let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-passkey";
let password = "unused";
let user_id = app.insert_user(username, password, "admin").await?;
let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?;
assert_ne!(passkey_id, Uuid::nil());
let bearer = app.login_token(username, password).await?;
let response = app
.upload_document_with_options(
"/api/documents",
"helper.txt",
"text/plain",
b"helper-content",
None,
Some("Helper Note"),
Some("{\"category\":\"note\"}"),
&bearer,
)
.await?;
assert!(response.status().is_success());
app.cleanup().await?;
Ok(())
}
}
async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
+2 -1
View File
@@ -14,7 +14,8 @@ struct DocumentDetail {
#[derive(Deserialize)]
struct DocumentSummary {
id: Uuid,
title: String,
#[serde(rename = "title")]
_title: String,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>,
}
+25 -12
View File
@@ -25,12 +25,8 @@ struct DocumentInfo {
metadata: Value,
#[serde(default)]
document_type_id: Option<Uuid>,
#[serde(default)]
document_type: Option<DocumentTypeInfo>,
tags: Vec<TagSummary>,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentInfo>,
#[serde(default)]
current_version: Option<DocumentVersionPayload>,
}
@@ -63,8 +59,6 @@ struct DocumentListItem {
#[serde(default)]
document_type_id: Option<Uuid>,
#[serde(default)]
document_type: Option<DocumentTypeInfo>,
#[serde(default)]
current_version: Option<DocumentVersionPayload>,
}
@@ -89,12 +83,6 @@ struct TagSummary {
label: String,
}
#[derive(Deserialize)]
struct DocumentCorrespondentInfo {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct DocumentTypeInfo {
id: Uuid,
@@ -196,6 +184,7 @@ async fn upload_and_list_document() -> Result<()> {
assert_eq!(detail.document.title, "doc");
assert_eq!(detail.document.deleted_at, None);
assert!(detail.document.issued_at.is_none());
assert!(detail.document.document_type_id.is_none());
assert!(detail.document.tags.is_empty());
let current_version = detail
.document
@@ -1490,6 +1479,8 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
assert_eq!(invoices.name, "Invoices");
assert_eq!(receipts.name, "Receipts");
let doc_a = app
.upload_document(
@@ -1591,6 +1582,28 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
assert!(combined_ids.contains(&detail_b.document.id));
assert!(!combined_ids.contains(&detail_c.document.id));
let refreshed_a = app
.get(
&format!("/api/documents/{}", detail_a.document.id),
Some(&token),
)
.await?;
assert!(refreshed_a.status().is_success());
let refreshed_a_body = body_to_vec(refreshed_a.into_body()).await?;
let refreshed_a_detail: DocumentDetail = serde_json::from_slice(&refreshed_a_body)?;
assert_eq!(refreshed_a_detail.document.document_type_id, Some(invoices.id));
let refreshed_b = app
.get(
&format!("/api/documents/{}", detail_b.document.id),
Some(&token),
)
.await?;
assert!(refreshed_b.status().is_success());
let refreshed_b_body = body_to_vec(refreshed_b.into_body()).await?;
let refreshed_b_detail: DocumentDetail = serde_json::from_slice(&refreshed_b_body)?;
assert_eq!(refreshed_b_detail.document.document_type_id, Some(receipts.id));
app.cleanup().await?;
Ok(())
}
+4 -4
View File
@@ -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
+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 };
+4 -6356
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -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;
}