tests
This commit is contained in:
@@ -53,8 +53,10 @@ struct SignupStartResponse {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct TenantSelectionResponse {
|
struct TenantSelectionResponse {
|
||||||
access_token: String,
|
#[serde(rename = "access_token")]
|
||||||
tenants: Vec<TenantSummary>,
|
_access_token: String,
|
||||||
|
#[serde(rename = "tenants")]
|
||||||
|
_tenants: Vec<TenantSummary>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -693,6 +693,62 @@ pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
|
|||||||
Ok(collected.to_bytes().to_vec())
|
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<()> {
|
async fn prepare_database(pool: &PgPool) -> Result<()> {
|
||||||
let pool = pool.clone();
|
let pool = pool.clone();
|
||||||
tokio::task::spawn_blocking(move || -> Result<()> {
|
tokio::task::spawn_blocking(move || -> Result<()> {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ struct DocumentDetail {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentSummary {
|
struct DocumentSummary {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
title: String,
|
#[serde(rename = "title")]
|
||||||
|
_title: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
correspondents: Vec<DocumentCorrespondentSummary>,
|
correspondents: Vec<DocumentCorrespondentSummary>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,8 @@ struct DocumentInfo {
|
|||||||
metadata: Value,
|
metadata: Value,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
document_type_id: Option<Uuid>,
|
document_type_id: Option<Uuid>,
|
||||||
#[serde(default)]
|
|
||||||
document_type: Option<DocumentTypeInfo>,
|
|
||||||
tags: Vec<TagSummary>,
|
tags: Vec<TagSummary>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
correspondents: Vec<DocumentCorrespondentInfo>,
|
|
||||||
#[serde(default)]
|
|
||||||
current_version: Option<DocumentVersionPayload>,
|
current_version: Option<DocumentVersionPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,8 +59,6 @@ struct DocumentListItem {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
document_type_id: Option<Uuid>,
|
document_type_id: Option<Uuid>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
document_type: Option<DocumentTypeInfo>,
|
|
||||||
#[serde(default)]
|
|
||||||
current_version: Option<DocumentVersionPayload>,
|
current_version: Option<DocumentVersionPayload>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,12 +83,6 @@ struct TagSummary {
|
|||||||
label: String,
|
label: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
|
||||||
struct DocumentCorrespondentInfo {
|
|
||||||
id: Uuid,
|
|
||||||
name: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct DocumentTypeInfo {
|
struct DocumentTypeInfo {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
@@ -196,6 +184,7 @@ async fn upload_and_list_document() -> Result<()> {
|
|||||||
assert_eq!(detail.document.title, "doc");
|
assert_eq!(detail.document.title, "doc");
|
||||||
assert_eq!(detail.document.deleted_at, None);
|
assert_eq!(detail.document.deleted_at, None);
|
||||||
assert!(detail.document.issued_at.is_none());
|
assert!(detail.document.issued_at.is_none());
|
||||||
|
assert!(detail.document.document_type_id.is_none());
|
||||||
assert!(detail.document.tags.is_empty());
|
assert!(detail.document.tags.is_empty());
|
||||||
let current_version = detail
|
let current_version = detail
|
||||||
.document
|
.document
|
||||||
@@ -1490,6 +1479,8 @@ async fn list_documents_filtered_by_document_type() -> Result<()> {
|
|||||||
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
|
assert_eq!(receipts_resp.status(), StatusCode::CREATED);
|
||||||
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
|
let receipts_body = body_to_vec(receipts_resp.into_body()).await?;
|
||||||
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
|
let receipts: DocumentTypeInfo = serde_json::from_slice(&receipts_body)?;
|
||||||
|
assert_eq!(invoices.name, "Invoices");
|
||||||
|
assert_eq!(receipts.name, "Receipts");
|
||||||
|
|
||||||
let doc_a = app
|
let doc_a = app
|
||||||
.upload_document(
|
.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_b.document.id));
|
||||||
assert!(!combined_ids.contains(&detail_c.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?;
|
app.cleanup().await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,14 +146,14 @@
|
|||||||
padding: 0.25em;
|
padding: 0.25em;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: none;
|
border: none;
|
||||||
background: oklch(0.22 0.06 260deg);
|
background: var(--preview-nav-bg);
|
||||||
color: var(--on-accent);
|
color: var(--preview-nav-fg);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.15s ease, opacity 0.15s ease;
|
transition: background 0.15s ease, opacity 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:hover:not([disabled]) {
|
.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 {
|
.skeuo-card__nav-button:disabled {
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.skeuo-card__nav-button:focus-visible {
|
.skeuo-card__nav-button:focus-visible {
|
||||||
outline: 2px solid var(--accent, #2684ff);
|
outline: 2px solid var(--accent);
|
||||||
outline-offset: 2px;
|
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;
|
gap: 0.4rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--danger, #d14343);
|
color: var(--danger);
|
||||||
font: inherit;
|
font: inherit;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0.4rem 0.5rem;
|
padding: 0.4rem 0.5rem;
|
||||||
@@ -2786,7 +2786,7 @@ form.inline {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.settings-form__error {
|
.settings-form__error {
|
||||||
color: var(--danger, #d14343);
|
color: var(--danger);
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
margin: 0 0 0.75rem;
|
margin: 0 0 0.75rem;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user