Harden auth and security controls with session auth and docs
This commit is contained in:
@@ -6,6 +6,7 @@ import type { JSX } from 'react';
|
||||
|
||||
import ActionModal from './components/ActionModal';
|
||||
import DocumentGrid from './components/DocumentGrid';
|
||||
import LoginScreen from './components/LoginScreen';
|
||||
import DocumentViewer from './components/DocumentViewer';
|
||||
import PathInput from './components/PathInput';
|
||||
import ProcessingLogPanel from './components/ProcessingLogPanel';
|
||||
@@ -17,22 +18,28 @@ import {
|
||||
downloadBlobFile,
|
||||
deleteDocument,
|
||||
exportContentsMarkdown,
|
||||
getCurrentAuthSession,
|
||||
getRuntimeApiToken,
|
||||
getAppSettings,
|
||||
listDocuments,
|
||||
listPaths,
|
||||
listProcessingLogs,
|
||||
listTags,
|
||||
listTypes,
|
||||
loginWithPassword,
|
||||
logoutCurrentSession,
|
||||
resetAppSettings,
|
||||
setRuntimeApiToken,
|
||||
searchDocuments,
|
||||
trashDocument,
|
||||
updateAppSettings,
|
||||
uploadDocuments,
|
||||
} from './lib/api';
|
||||
import type { AppSettings, AppSettingsUpdate, DmsDocument, ProcessingLogEntry } from './types';
|
||||
import type { AppSettings, AppSettingsUpdate, AuthUser, DmsDocument, ProcessingLogEntry } from './types';
|
||||
|
||||
type AppScreen = 'documents' | 'settings';
|
||||
type DocumentView = 'active' | 'trash';
|
||||
type AuthPhase = 'checking' | 'unauthenticated' | 'authenticated';
|
||||
|
||||
interface DialogOption {
|
||||
key: string;
|
||||
@@ -51,6 +58,10 @@ interface DialogState {
|
||||
*/
|
||||
export default function App(): JSX.Element {
|
||||
const DEFAULT_PAGE_SIZE = 12;
|
||||
const [authPhase, setAuthPhase] = useState<AuthPhase>('checking');
|
||||
const [authUser, setAuthUser] = useState<AuthUser | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [isAuthenticating, setIsAuthenticating] = useState<boolean>(false);
|
||||
const [screen, setScreen] = useState<AppScreen>('documents');
|
||||
const [documentView, setDocumentView] = useState<DocumentView>('active');
|
||||
const [documents, setDocuments] = useState<DmsDocument[]>([]);
|
||||
@@ -82,6 +93,7 @@ export default function App(): JSX.Element {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [dialogState, setDialogState] = useState<DialogState | null>(null);
|
||||
const dialogResolverRef = useRef<((value: string) => void) | null>(null);
|
||||
const isAdmin = authUser?.role === 'admin';
|
||||
|
||||
const pageSize = useMemo(() => {
|
||||
const configured = appSettings?.display?.cards_per_page;
|
||||
@@ -118,6 +130,74 @@ export default function App(): JSX.Element {
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clears workspace state when authentication context changes or session is revoked.
|
||||
*/
|
||||
const resetApplicationState = useCallback((): void => {
|
||||
setScreen('documents');
|
||||
setDocumentView('active');
|
||||
setDocuments([]);
|
||||
setTotalDocuments(0);
|
||||
setCurrentPage(1);
|
||||
setSearchText('');
|
||||
setActiveSearchQuery('');
|
||||
setSelectedDocumentId(null);
|
||||
setSelectedDocumentIds([]);
|
||||
setExportPathInput('');
|
||||
setTagFilter('');
|
||||
setTypeFilter('');
|
||||
setPathFilter('');
|
||||
setProcessedFrom('');
|
||||
setProcessedTo('');
|
||||
setKnownTags([]);
|
||||
setKnownPaths([]);
|
||||
setKnownTypes([]);
|
||||
setAppSettings(null);
|
||||
setSettingsSaveAction(null);
|
||||
setProcessingLogs([]);
|
||||
setProcessingLogError(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Exchanges submitted credentials for server-issued bearer session and activates app shell.
|
||||
*/
|
||||
const handleLogin = useCallback(async (username: string, password: string): Promise<void> => {
|
||||
setIsAuthenticating(true);
|
||||
setAuthError(null);
|
||||
try {
|
||||
const payload = await loginWithPassword(username, password);
|
||||
setRuntimeApiToken(payload.access_token);
|
||||
setAuthUser(payload.user);
|
||||
setAuthPhase('authenticated');
|
||||
setError(null);
|
||||
} catch (caughtError) {
|
||||
const message = caughtError instanceof Error ? caughtError.message : 'Login failed';
|
||||
setAuthError(message);
|
||||
setRuntimeApiToken(null);
|
||||
setAuthUser(null);
|
||||
setAuthPhase('unauthenticated');
|
||||
resetApplicationState();
|
||||
} finally {
|
||||
setIsAuthenticating(false);
|
||||
}
|
||||
}, [resetApplicationState]);
|
||||
|
||||
/**
|
||||
* Revokes current session server-side when possible and always clears local auth state.
|
||||
*/
|
||||
const handleLogout = useCallback(async (): Promise<void> => {
|
||||
setError(null);
|
||||
try {
|
||||
await logoutCurrentSession();
|
||||
} catch {}
|
||||
setRuntimeApiToken(null);
|
||||
setAuthUser(null);
|
||||
setAuthError(null);
|
||||
setAuthPhase('unauthenticated');
|
||||
resetApplicationState();
|
||||
}, [resetApplicationState]);
|
||||
|
||||
const loadCatalogs = useCallback(async (): Promise<void> => {
|
||||
const [tags, paths, types] = await Promise.all([listTags(true), listPaths(true), listTypes(true)]);
|
||||
setKnownTags(tags);
|
||||
@@ -185,6 +265,10 @@ export default function App(): JSX.Element {
|
||||
]);
|
||||
|
||||
const loadSettings = useCallback(async (): Promise<void> => {
|
||||
if (!isAdmin) {
|
||||
setAppSettings(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await getAppSettings();
|
||||
@@ -192,9 +276,14 @@ export default function App(): JSX.Element {
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to load settings');
|
||||
}
|
||||
}, []);
|
||||
}, [isAdmin]);
|
||||
|
||||
const loadProcessingTimeline = useCallback(async (options?: { silent?: boolean }): Promise<void> => {
|
||||
if (!isAdmin) {
|
||||
setProcessingLogs([]);
|
||||
setProcessingLogError(null);
|
||||
return;
|
||||
}
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) {
|
||||
setIsLoadingLogs(true);
|
||||
@@ -210,18 +299,52 @@ export default function App(): JSX.Element {
|
||||
setIsLoadingLogs(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
const existingToken = getRuntimeApiToken();
|
||||
if (!existingToken) {
|
||||
setAuthPhase('unauthenticated');
|
||||
setAuthUser(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const resolveSession = async (): Promise<void> => {
|
||||
try {
|
||||
const sessionPayload = await getCurrentAuthSession();
|
||||
setAuthUser(sessionPayload.user);
|
||||
setAuthError(null);
|
||||
setAuthPhase('authenticated');
|
||||
} catch {
|
||||
setRuntimeApiToken(null);
|
||||
setAuthUser(null);
|
||||
setAuthPhase('unauthenticated');
|
||||
resetApplicationState();
|
||||
}
|
||||
};
|
||||
void resolveSession();
|
||||
}, [resetApplicationState]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authPhase !== 'authenticated') {
|
||||
return;
|
||||
}
|
||||
const bootstrap = async (): Promise<void> => {
|
||||
try {
|
||||
await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]);
|
||||
if (isAdmin) {
|
||||
await Promise.all([loadDocuments(), loadCatalogs(), loadSettings(), loadProcessingTimeline()]);
|
||||
return;
|
||||
}
|
||||
await Promise.all([loadDocuments(), loadCatalogs()]);
|
||||
setAppSettings(null);
|
||||
setProcessingLogs([]);
|
||||
setProcessingLogError(null);
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Failed to initialize application');
|
||||
}
|
||||
};
|
||||
void bootstrap();
|
||||
}, [loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
|
||||
}, [authPhase, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, loadSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedDocumentIds([]);
|
||||
@@ -229,13 +352,25 @@ export default function App(): JSX.Element {
|
||||
}, [documentView, pageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin && screen === 'settings') {
|
||||
setScreen('documents');
|
||||
}
|
||||
}, [isAdmin, screen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authPhase !== 'authenticated') {
|
||||
return;
|
||||
}
|
||||
if (screen !== 'documents') {
|
||||
return;
|
||||
}
|
||||
void loadDocuments();
|
||||
}, [loadDocuments, screen]);
|
||||
}, [authPhase, loadDocuments, screen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authPhase !== 'authenticated') {
|
||||
return;
|
||||
}
|
||||
if (screen !== 'documents') {
|
||||
return;
|
||||
}
|
||||
@@ -243,9 +378,12 @@ export default function App(): JSX.Element {
|
||||
void loadDocuments({ silent: true });
|
||||
}, 3000);
|
||||
return () => window.clearInterval(pollInterval);
|
||||
}, [loadDocuments, screen]);
|
||||
}, [authPhase, loadDocuments, screen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (authPhase !== 'authenticated' || !isAdmin) {
|
||||
return;
|
||||
}
|
||||
if (screen !== 'documents') {
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +392,7 @@ export default function App(): JSX.Element {
|
||||
void loadProcessingTimeline({ silent: true });
|
||||
}, 1500);
|
||||
return () => window.clearInterval(pollInterval);
|
||||
}, [loadProcessingTimeline, screen]);
|
||||
}, [authPhase, isAdmin, loadProcessingTimeline, screen]);
|
||||
|
||||
const selectedDocument = useMemo(
|
||||
() => documents.find((document) => document.id === selectedDocumentId) ?? null,
|
||||
@@ -299,13 +437,17 @@ export default function App(): JSX.Element {
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]);
|
||||
if (isAdmin) {
|
||||
await Promise.all([loadDocuments(), loadCatalogs(), loadProcessingTimeline()]);
|
||||
} else {
|
||||
await Promise.all([loadDocuments(), loadCatalogs()]);
|
||||
}
|
||||
} catch (caughtError) {
|
||||
setError(caughtError instanceof Error ? caughtError.message : 'Upload failed');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}, [appSettings, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]);
|
||||
}, [appSettings, isAdmin, loadCatalogs, loadDocuments, loadProcessingTimeline, presentDialog]);
|
||||
|
||||
const handleSearch = useCallback(async (): Promise<void> => {
|
||||
setSelectedDocumentIds([]);
|
||||
@@ -579,6 +721,21 @@ export default function App(): JSX.Element {
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
if (authPhase === 'checking') {
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card">
|
||||
<h1>LedgerDock</h1>
|
||||
<p>Checking current session...</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (authPhase !== 'authenticated') {
|
||||
return <LoginScreen error={authError} isSubmitting={isAuthenticating} onSubmit={handleLogin} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<header className="topbar">
|
||||
@@ -608,12 +765,23 @@ export default function App(): JSX.Element {
|
||||
>
|
||||
Trash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={screen === 'settings' ? 'active-view-button' : 'secondary-action'}
|
||||
onClick={() => setScreen('settings')}
|
||||
>
|
||||
Settings
|
||||
{isAdmin && (
|
||||
<button
|
||||
type="button"
|
||||
className={screen === 'settings' ? 'active-view-button' : 'secondary-action'}
|
||||
onClick={() => setScreen('settings')}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="topbar-auth-group">
|
||||
<span className="auth-user-badge">
|
||||
{authUser?.username} ({authUser?.role})
|
||||
</span>
|
||||
<button type="button" className="secondary-action" onClick={() => void handleLogout()}>
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -623,7 +791,7 @@ export default function App(): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{screen === 'settings' && (
|
||||
{screen === 'settings' && isAdmin && (
|
||||
<div className="topbar-settings-group">
|
||||
<button type="button" className="secondary-action" onClick={() => void handleResetSettings()} disabled={isSavingSettings}>
|
||||
Reset To Defaults
|
||||
@@ -638,7 +806,7 @@ export default function App(): JSX.Element {
|
||||
|
||||
{error && <p className="error-banner">{error}</p>}
|
||||
|
||||
{screen === 'settings' && (
|
||||
{screen === 'settings' && isAdmin && (
|
||||
<SettingsScreen
|
||||
settings={appSettings}
|
||||
isSaving={isSavingSettings}
|
||||
@@ -762,16 +930,18 @@ export default function App(): JSX.Element {
|
||||
requestConfirmation={requestConfirmation}
|
||||
/>
|
||||
</section>
|
||||
{processingLogError && <p className="error-banner">{processingLogError}</p>}
|
||||
<ProcessingLogPanel
|
||||
entries={processingLogs}
|
||||
isLoading={isLoadingLogs}
|
||||
isClearing={isClearingLogs}
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
isProcessingActive={isProcessingActive}
|
||||
typingAnimationEnabled={typingAnimationEnabled}
|
||||
onClear={() => void handleClearProcessingLogs()}
|
||||
/>
|
||||
{isAdmin && processingLogError && <p className="error-banner">{processingLogError}</p>}
|
||||
{isAdmin && (
|
||||
<ProcessingLogPanel
|
||||
entries={processingLogs}
|
||||
isLoading={isLoadingLogs}
|
||||
isClearing={isClearingLogs}
|
||||
selectedDocumentId={selectedDocumentId}
|
||||
isProcessingActive={isProcessingActive}
|
||||
typingAnimationEnabled={typingAnimationEnabled}
|
||||
onClear={() => void handleClearProcessingLogs()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user