Skip to content
Merged
4 changes: 2 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ function SessionGate() {
</Routes></Suspense>;
}

function BootScreen({ label = 'Finding the local server' }: { label?: string }) {
function BootScreen({ label = 'Connecting to Overroot' }: { label?: string }) {
return <div className="boot-screen"><Brand/><LoadingBlock label={label}/></div>;
}

function OfflineScreen({ message, retry }: { message: string; retry: () => void }) {
return <div className="offline-screen"><Brand/><div className="offline-card"><ServerOff size={28}/><div className="eyebrow">Server sleeping</div><h1>Overroot is offline.</h1><p>{message}. The documents remain safely on the host machine.</p><button type="button" className="primary-button" onClick={retry}><RefreshCw size={15}/> Try again</button></div></div>;
return <div className="offline-screen"><Brand/><div className="offline-card"><ServerOff size={28}/><div className="eyebrow">Connection unavailable</div><h1>Could not connect to Overroot.</h1><p>{message}. Check your connection and try again.</p><button type="button" className="primary-button" onClick={retry}><RefreshCw size={15}/> Try again</button></div></div>;
}
33 changes: 25 additions & 8 deletions apps/web/src/components/DashboardDialogs.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CalendarClock, Check, KeyRound, Link2, Plus, Trash2 } from 'lucide-react';
import { useEffect, useState, type FormEvent } from 'react';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import type { ApiClient, CreatedInvite, InviteSummary } from '../lib/api';
import { relativeTime } from '../lib/format';
import type { Template } from '../types';
Expand All @@ -12,36 +12,53 @@ export function NewProjectDialog({
open,
templates,
onClose,
onCreate
onCreate,
onPendingChange
}: {
open: boolean;
templates: Template[];
onClose: () => void;
onCreate: (name: string, template: string) => Promise<void>;
onPendingChange?: (pending: boolean) => void;
}) {
const [name, setName] = useState('Untitled paper');
const [template, setTemplate] = useState('article');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => { if (open) { setName('Untitled paper'); setTemplate(templates[0]?.id ?? 'article'); setError(null); } }, [open, templates]);
const active = useRef(false);
const visible = useRef(open);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => {
visible.current = open;
// Reopening an in-flight operation must show the draft actually being created.
if (open && !busy) { setName('Untitled paper'); setTemplate(templates[0]?.id ?? 'article'); setError(null); }
}, [open]);
async function submit(event: FormEvent) {
event.preventDefault();
if (busy) return;
setBusy(true); setError(null);
try { await onCreate(name, template); onClose(); } catch (reason) { setError(reason instanceof Error ? reason.message : 'Could not create project'); } finally { setBusy(false); }
setBusy(true); setError(null); onPendingChange?.(true);
try {
await onCreate(name, template);
if (active.current && visible.current) onClose();
} catch (reason) {
if (active.current) setError(reason instanceof Error ? reason.message : 'Could not create project');
} finally {
if (active.current) { setBusy(false); onPendingChange?.(false); }
}
}
return <Modal open={open} onClose={onClose} title="Start a new project" eyebrow="New project" size="large">
<form onSubmit={submit} className="stack-form">
<label className="field"><span>Project name</span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} maxLength={100} required/></label>
<fieldset className="template-grid"><legend>Choose a starting point</legend>
<label className="field"><span>Project name</span><input autoFocus value={name} onChange={(event) => setName(event.target.value)} maxLength={100} required disabled={busy}/></label>
<fieldset className="template-grid" disabled={busy}><legend>Choose a starting point</legend>
{templates.map((item) => <label key={item.id} className={`template-card ${template === item.id ? 'is-selected' : ''}`}>
<input type="radio" name="template" value={item.id} checked={template === item.id} onChange={() => setTemplate(item.id)}/>
<span className="template-card__check">{template === item.id && <Check size={13}/>}</span>
<strong>{item.name}</strong><small>{item.description}</small><code>{item.entryFile}</code>
</label>)}
</fieldset>
<InlineError message={error}/>
<div className="form-actions"><button type="button" className="plain-button" onClick={onClose}>Cancel</button><button type="submit" className="primary-button" disabled={busy || !name.trim()} aria-busy={busy} aria-label={busy ? 'Creating project…' : undefined}>{busy ? <PendingLabel>Creating project…</PendingLabel> : 'Create project'}</button></div>
{busy && <p className="dialog-copy">You can close this dialog while the project is created.</p>}
<div className="form-actions"><button type="button" className="plain-button" onClick={onClose}>{busy ? 'Close' : 'Cancel'}</button><button type="submit" className="primary-button" disabled={busy || !name.trim()} aria-busy={busy} aria-label={busy ? 'Creating project…' : undefined}>{busy ? <PendingLabel>Creating project…</PendingLabel> : 'Create project'}</button></div>
</form>
</Modal>;
}
Expand Down
73 changes: 73 additions & 0 deletions apps/web/src/components/PdfViewer.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import type { ApiClient } from '../lib/api';
import { PdfViewer } from './PdfViewer';

const parser = vi.hoisted(() => ({ getDocument: vi.fn() }));
vi.mock('pdfjs-dist', () => ({ GlobalWorkerOptions: {}, getDocument: parser.getDocument }));

function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: Error) => void;
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
return { promise, resolve, reject };
}
function document() {
return { numPages: 1, getPage: vi.fn(async () => ({ getViewport: () => ({ width: 200, height: 300 }) })) };
}
function properties(data = new ArrayBuffer(1)) {
return {
api: {} as ApiClient, projectHash: 'pdf-validation-test', data, revision: 'source', synctexJobId: null,
sourceLocation: null, loading: false, focusMode: false, onFocusMode: vi.fn(), projectName: 'Paper',
onDocumentStatus: vi.fn()
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null);
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; });
});
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });

it('reports parser rejection with the exact downloaded buffer identity', async () => {
const props = properties(new Uint8Array([0]).buffer);
parser.getDocument.mockImplementation(() => ({ promise: Promise.reject(new Error('Invalid PDF structure')), destroy: vi.fn() }));
render(<PdfViewer {...props}/>);
await screen.findByText('Invalid PDF structure');
expect(props.onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'failed');
});

it('confirms usable bytes after pages finish loading', async () => {
const props = properties();
parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() }));
render(<PdfViewer {...props}/>);
await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'ready'));
expect(screen.getByLabelText('PDF page 1')).toBeInTheDocument();
});

it('does not report a cancelled old parser failure against replacement bytes', async () => {
const first = deferred<ReturnType<typeof document>>();
parser.getDocument.mockReturnValueOnce({ promise: first.promise, destroy: vi.fn() });
parser.getDocument.mockImplementation(() => ({ promise: Promise.resolve(document()), destroy: vi.fn() }));
const props = properties();
const view = render(<PdfViewer {...props}/>);
const replacement = new ArrayBuffer(2);
view.rerender(<PdfViewer {...props} data={replacement}/>);
await waitFor(() => expect(props.onDocumentStatus).toHaveBeenCalledWith(replacement, 'ready'));
await act(async () => { first.reject(new Error('Old PDF was invalid')); });
expect(props.onDocumentStatus).not.toHaveBeenCalledWith(props.data, 'failed');
expect(screen.queryByText('Old PDF was invalid')).not.toBeInTheDocument();
});

it('uses the latest validation callback without restarting a pending document', async () => {
const pending = deferred<ReturnType<typeof document>>();
parser.getDocument.mockReturnValue({ promise: pending.promise, destroy: vi.fn() });
const props = properties();
const view = render(<PdfViewer {...props}/>);
const onDocumentStatus = vi.fn();
view.rerender(<PdfViewer {...props} onDocumentStatus={onDocumentStatus}/>);
await act(async () => { pending.resolve(document()); });
expect(parser.getDocument).toHaveBeenCalledTimes(1);
expect(onDocumentStatus).toHaveBeenCalledExactlyOnceWith(props.data, 'ready');
expect(props.onDocumentStatus).not.toHaveBeenCalled();
});
8 changes: 7 additions & 1 deletion apps/web/src/components/PdfViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export function PdfViewer({
loading,
focusMode,
onFocusMode,
projectName
projectName,
onDocumentStatus
}: {
api: ApiClient;
projectHash: string;
Expand All @@ -33,6 +34,7 @@ export function PdfViewer({
focusMode: boolean;
onFocusMode: (focused: boolean) => void;
projectName: string;
onDocumentStatus?: (data: ArrayBuffer, status: 'ready' | 'failed') => void;
}) {
const scroller = useRef<HTMLDivElement>(null);
const pages = useRef<HTMLDivElement>(null);
Expand All @@ -48,6 +50,8 @@ export function PdfViewer({
const [rendering, setRendering] = useState(false);
const [error, setError] = useState<string | null>(null);
const [sourceHighlights, setSourceHighlights] = useState<SyncTexHighlight[]>([]);
const documentStatus = useRef(onDocumentStatus);
documentStatus.current = onDocumentStatus;

useEffect(() => {
if (!data || !pages.current) {
Expand Down Expand Up @@ -95,6 +99,7 @@ export function PdfViewer({
await Promise.all(renderJobs);
if (cancelled) return;
setRendering(false);
documentStatus.current?.(data, 'ready');
setPage(Math.min(preserved.current.page, pdf.numPages));
afterLayout(() => {
if (cancelled) return;
Expand All @@ -108,6 +113,7 @@ export function PdfViewer({
canPersist.current = Boolean(pages.current?.childElementCount);
setRendering(false);
setError(reason instanceof Error ? reason.message : 'The PDF could not be displayed');
documentStatus.current?.(data, 'failed');
});

return () => {
Expand Down
37 changes: 37 additions & 0 deletions apps/web/src/pages/AuthPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,43 @@ beforeEach(() => {
afterEach(cleanup);

describe('authentication progress', () => {
it('ignores a late sign-in result after switching to registration', async () => {
const pending = deferred<{ token: string; user: { username: string } }>();
harness.api.login.mockReturnValueOnce(pending.promise);
render(<MemoryRouter initialEntries={['/login']}><Routes><Route path="/login" element={<AuthPage mode="login"/>}/><Route path="/register" element={<AuthPage mode="register"/>}/><Route path="/" element={<div>Project dashboard</div>}/></Routes></MemoryRouter>);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'writer' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'test-password' } });
fireEvent.submit(screen.getByLabelText('Password').closest('form')!);
fireEvent.click(screen.getByRole('link', { name: 'Create an account' }));
expect(screen.getByRole('button', { name: 'Join Overroot' })).toBeEnabled();
await act(async () => { pending.resolve({ token: 'stale-token', user: { username: 'writer' } }); });
expect(harness.setSession).not.toHaveBeenCalled();
expect(screen.getByRole('heading', { name: 'Create an account' })).toBeInTheDocument();
expect(screen.queryByText('Project dashboard')).not.toBeInTheDocument();
});

it('keeps current registration progress when the previous sign-in fails late', async () => {
const login = deferred<{ token: string; user: { username: string } }>();
const register = deferred<{ token: string; user: { username: string } }>();
harness.api.login.mockReturnValueOnce(login.promise);
harness.api.register.mockReturnValueOnce(register.promise);
render(<MemoryRouter initialEntries={['/login']}><Routes><Route path="/login" element={<AuthPage mode="login"/>}/><Route path="/register" element={<AuthPage mode="register"/>}/><Route path="/" element={<div>Project dashboard</div>}/></Routes></MemoryRouter>);
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'writer' } });
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'test-password' } });
fireEvent.submit(screen.getByLabelText('Password').closest('form')!);
fireEvent.click(screen.getByRole('link', { name: 'Create an account' }));
fireEvent.change(screen.getByLabelText('Invitation'), { target: { value: 'invitation' } });
fireEvent.submit(screen.getByLabelText('Password').closest('form')!);
await act(async () => { login.reject(new Error('Old login rejected')); });
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Creating account…' })).toBeDisabled();
expect(screen.getByRole('status')).toHaveTextContent('Creating account…');
const session = { token: 'current-token', user: { username: 'writer' } };
await act(async () => { register.resolve(session); });
expect(harness.setSession).toHaveBeenCalledExactlyOnceWith(session.token, session.user);
expect(screen.getByText('Project dashboard')).toBeInTheDocument();
});

it.each(['login', 'register'] as const)('keeps %s progress tied to the request and recovers after failure', async (mode) => {
const pending = deferred<{ token: string; user: { username: string } }>();
harness.api[mode].mockReturnValue(pending.promise);
Expand Down
19 changes: 15 additions & 4 deletions apps/web/src/pages/AuthPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ArrowRight, FileText, KeyRound, Layers, Users } from 'lucide-react';
import { useEffect, useState, type FormEvent } from 'react';
import { useEffect, useRef, useState, type FormEvent } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { Brand } from '../components/Brand';
import { InlineError } from '../components/Feedback';
Expand All @@ -19,23 +19,34 @@ export function AuthPage({ mode }: { mode: 'login' | 'register' }) {
const [busy, setBusy] = useState(false);
const [setupRequired, setSetupRequired] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestId = useRef(0);
const progressLabel = mode === 'login' ? 'Signing in…' : 'Creating account…';

useEffect(() => {
let active = true;
if (token) navigate('/', { replace: true });
void api.request<{ setupRequired: boolean }>('/api/v1/status').then((status) => setSetupRequired(status.setupRequired)).catch(() => {});
void api.request<{ setupRequired: boolean }>('/api/v1/status').then((status) => { if (active) setSetupRequired(status.setupRequired); }).catch(() => {});
return () => { active = false; };
}, [api, token, navigate]);

useEffect(() => {
requestId.current += 1;
setBusy(false); setError(null);
return () => { requestId.current += 1; };
}, [api, mode]);

async function submit(event: FormEvent) {
event.preventDefault();
if (busy) return;
const currentRequest = ++requestId.current;
setBusy(true); setError(null);
try {
const session = mode === 'login' ? await api.login(username, password) : await api.register(invite, username, password);
if (currentRequest !== requestId.current) return;
setSession(session.token, session.user);
navigate('/', { replace: true });
} catch (reason) { setError(reason instanceof Error ? reason.message : 'Authentication failed'); }
finally { setBusy(false); }
} catch (reason) { if (currentRequest === requestId.current) setError(reason instanceof Error ? reason.message : 'Authentication failed'); }
finally { if (currentRequest === requestId.current) setBusy(false); }
}

return <div className="auth-shell">
Expand Down
Loading
Loading