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()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
71
frontend/src/components/LoginScreen.tsx
Normal file
71
frontend/src/components/LoginScreen.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Login screen for session-based authentication before loading protected application views.
|
||||
*/
|
||||
import { FormEvent, useState } from 'react';
|
||||
import type { JSX } from 'react';
|
||||
|
||||
interface LoginScreenProps {
|
||||
error: string | null;
|
||||
isSubmitting: boolean;
|
||||
onSubmit: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders credential form used to issue per-user API bearer sessions.
|
||||
*/
|
||||
export default function LoginScreen({
|
||||
error,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
}: LoginScreenProps): JSX.Element {
|
||||
const [username, setUsername] = useState<string>('');
|
||||
const [password, setPassword] = useState<string>('');
|
||||
|
||||
/**
|
||||
* Submits credentials and leaves result handling to parent application orchestration.
|
||||
*/
|
||||
const handleSubmit = (event: FormEvent<HTMLFormElement>): void => {
|
||||
event.preventDefault();
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
void onSubmit(username, password);
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="auth-shell">
|
||||
<section className="auth-card">
|
||||
<h1>LedgerDock</h1>
|
||||
<p>Sign in with your account to access documents and role-scoped controls.</p>
|
||||
<form onSubmit={handleSubmit} className="auth-form">
|
||||
<label>
|
||||
Username
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(event) => setUsername(event.target.value)}
|
||||
autoComplete="username"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="current-password"
|
||||
required
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Signing In...' : 'Sign In'}
|
||||
</button>
|
||||
</form>
|
||||
{error && <p className="error-banner">{error}</p>}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,16 @@
|
||||
// @ts-expect-error Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import { API_TOKEN_RUNTIME_GLOBAL_KEY, downloadDocumentContentMarkdown, downloadDocumentFile, getDocumentPreviewBlob, getDocumentThumbnailBlob, setApiTokenResolver, setRuntimeApiToken, updateDocumentMetadata } from './api.ts';
|
||||
// @ts-ignore Node strip-types runtime requires explicit .ts extension in ESM imports.
|
||||
import {
|
||||
downloadDocumentContentMarkdown,
|
||||
downloadDocumentFile,
|
||||
getCurrentAuthSession,
|
||||
getDocumentPreviewBlob,
|
||||
getDocumentThumbnailBlob,
|
||||
getRuntimeApiToken,
|
||||
loginWithPassword,
|
||||
logoutCurrentSession,
|
||||
setRuntimeApiToken,
|
||||
updateDocumentMetadata,
|
||||
} from './api.ts';
|
||||
|
||||
/**
|
||||
* Throws when a test condition is false.
|
||||
@@ -65,12 +76,10 @@ function createMemorySessionStorage(): Storage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs API helper tests for authenticated media and download flows.
|
||||
* Runs API helper tests for authenticated media and auth session workflows.
|
||||
*/
|
||||
async function runApiTests(): Promise<void> {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const runtimeGlobalSource = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
const originalRuntimeGlobalToken = runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
const sessionStorageDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'sessionStorage');
|
||||
|
||||
try {
|
||||
@@ -79,9 +88,7 @@ async function runApiTests(): Promise<void> {
|
||||
writable: true,
|
||||
value: createMemorySessionStorage(),
|
||||
});
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
|
||||
const requestUrls: string[] = [];
|
||||
const requestAuthHeaders: Array<string | null> = [];
|
||||
@@ -108,6 +115,7 @@ async function runApiTests(): Promise<void> {
|
||||
assert(requestAuthHeaders[1] === null, `Expected no auth header for preview request, got "${requestAuthHeaders[1]}"`);
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
assert(getRuntimeApiToken() === 'session-user-token', 'Expected session token readback to match persisted token');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer session-user-token', `Expected session token auth header, got "${authHeader}"`);
|
||||
@@ -115,16 +123,6 @@ async function runApiTests(): Promise<void> {
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-session-auth');
|
||||
|
||||
setRuntimeApiToken('session-user-token');
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = 'runtime-global-token';
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected global runtime token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-global-auth');
|
||||
|
||||
setApiTokenResolver(() => 'resolver-token');
|
||||
let mergedContentType: string | null = null;
|
||||
let mergedAuthorization: string | null = null;
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
@@ -135,19 +133,47 @@ async function runApiTests(): Promise<void> {
|
||||
}) as typeof fetch;
|
||||
await updateDocumentMetadata('doc-headers', { original_filename: 'renamed.pdf' });
|
||||
assert(mergedContentType === 'application/json', `Expected JSON content type to be preserved, got "${mergedContentType}"`);
|
||||
assert(mergedAuthorization === 'Bearer resolver-token', `Expected resolver token auth header, got "${mergedAuthorization}"`);
|
||||
assert(mergedAuthorization === 'Bearer session-user-token', `Expected auth header, got "${mergedAuthorization}"`);
|
||||
|
||||
setApiTokenResolver(() => ' ');
|
||||
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
||||
const authHeader = new Headers(init?.headers).get('Authorization');
|
||||
assert(authHeader === 'Bearer runtime-global-token', `Expected fallback runtime global token auth header, got "${authHeader}"`);
|
||||
return new Response('preview-bytes', { status: 200 });
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'issued-session-token',
|
||||
token_type: 'bearer',
|
||||
expires_at: '2026-03-01T10:30:00Z',
|
||||
user: {
|
||||
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
await getDocumentPreviewBlob('doc-resolver-fallback');
|
||||
const loginPayload = await loginWithPassword('admin', 'password');
|
||||
assert(loginPayload.access_token === 'issued-session-token', 'Unexpected issued session token in login payload');
|
||||
assert(loginPayload.user.username === 'admin', 'Unexpected login user payload');
|
||||
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
expires_at: '2026-03-01T10:30:00Z',
|
||||
user: {
|
||||
id: '3a42f5e0-b1ad-4f68-b2f4-3fa8c2fb31c9',
|
||||
username: 'admin',
|
||||
role: 'admin',
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}) as typeof fetch;
|
||||
const sessionPayload = await getCurrentAuthSession();
|
||||
assert(sessionPayload.user.role === 'admin', 'Expected admin role from auth session payload');
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('{}', { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||
}) as typeof fetch;
|
||||
await logoutCurrentSession();
|
||||
|
||||
globalThis.fetch = (async (): Promise<Response> => {
|
||||
return new Response('file-bytes', {
|
||||
@@ -176,13 +202,7 @@ async function runApiTests(): Promise<void> {
|
||||
|
||||
await assertRejects(async () => downloadDocumentContentMarkdown('doc-4'), 'Failed to download document markdown');
|
||||
} finally {
|
||||
setApiTokenResolver(null);
|
||||
setRuntimeApiToken(null);
|
||||
if (originalRuntimeGlobalToken === undefined) {
|
||||
delete runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY];
|
||||
} else {
|
||||
runtimeGlobalSource[API_TOKEN_RUNTIME_GLOBAL_KEY] = originalRuntimeGlobalToken;
|
||||
}
|
||||
if (sessionStorageDescriptor) {
|
||||
Object.defineProperty(globalThis, 'sessionStorage', sessionStorageDescriptor);
|
||||
} else {
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
AuthLoginResponse,
|
||||
AuthSessionInfo,
|
||||
DocumentListResponse,
|
||||
DmsDocument,
|
||||
DmsDocumentDetail,
|
||||
@@ -33,27 +35,10 @@ function resolveApiBase(): string {
|
||||
|
||||
const API_BASE = resolveApiBase();
|
||||
|
||||
/**
|
||||
* Legacy environment token fallback used only when no runtime token source is available.
|
||||
*/
|
||||
const LEGACY_API_TOKEN = normalizeBearerToken(import.meta.env?.VITE_API_TOKEN);
|
||||
|
||||
/**
|
||||
* Global property name used for runtime token injection.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_GLOBAL_KEY = '__DCM_API_TOKEN__';
|
||||
|
||||
/**
|
||||
* Session storage key used for per-user runtime token persistence.
|
||||
*/
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.api_token';
|
||||
|
||||
/**
|
||||
* Resolves a bearer token dynamically at request time.
|
||||
*/
|
||||
export type ApiTokenResolver = () => string | null | undefined;
|
||||
|
||||
let runtimeTokenResolver: ApiTokenResolver | null = null;
|
||||
export const API_TOKEN_RUNTIME_STORAGE_KEY = 'dcm.access_token';
|
||||
|
||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||
|
||||
@@ -71,17 +56,9 @@ function normalizeBearerToken(candidate: unknown): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from mutable global injection points when available.
|
||||
* Resolves bearer token persisted for current browser session.
|
||||
*/
|
||||
function resolveGlobalRuntimeToken(): string | undefined {
|
||||
const source = globalThis as typeof globalThis & Record<string, unknown>;
|
||||
return normalizeBearerToken(source[API_TOKEN_RUNTIME_GLOBAL_KEY]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves runtime token from session storage where per-user state can be isolated by browser session.
|
||||
*/
|
||||
function resolveSessionStorageToken(): string | undefined {
|
||||
export function getRuntimeApiToken(): string | undefined {
|
||||
if (typeof globalThis.sessionStorage === 'undefined') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -93,31 +70,10 @@ function resolveSessionStorageToken(): string | undefined {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves bearer token using runtime sources first, then legacy environment fallback for compatibility.
|
||||
* Resolves bearer token from authenticated browser-session storage.
|
||||
*/
|
||||
function resolveApiToken(): string | undefined {
|
||||
const resolverToken = normalizeBearerToken(runtimeTokenResolver?.());
|
||||
if (resolverToken) {
|
||||
return resolverToken;
|
||||
}
|
||||
const globalRuntimeToken = resolveGlobalRuntimeToken();
|
||||
if (globalRuntimeToken) {
|
||||
return globalRuntimeToken;
|
||||
}
|
||||
const sessionStorageToken = resolveSessionStorageToken();
|
||||
if (sessionStorageToken) {
|
||||
return sessionStorageToken;
|
||||
}
|
||||
return LEGACY_API_TOKEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers or clears a request-time bearer token resolver used by API helpers.
|
||||
*
|
||||
* @param resolver Function returning a token for each request, or `null` to remove custom resolution.
|
||||
*/
|
||||
export function setApiTokenResolver(resolver: ApiTokenResolver | null): void {
|
||||
runtimeTokenResolver = resolver;
|
||||
return getRuntimeApiToken();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -226,6 +182,59 @@ export function downloadBlobFile(blob: Blob, filename: string): void {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticates one user and returns issued bearer token plus role-bound session metadata.
|
||||
*/
|
||||
export async function loginWithPassword(username: string, password: string): Promise<AuthLoginResponse> {
|
||||
const response = await fetch(`${API_BASE}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: username.trim(),
|
||||
password,
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
return response.json() as Promise<AuthLoginResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads currently authenticated user session metadata.
|
||||
*/
|
||||
export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
||||
const response = await apiRequest(`${API_BASE}/auth/me`);
|
||||
if (!response.ok) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Failed to load authentication session');
|
||||
}
|
||||
return response.json() as Promise<AuthSessionInfo>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revokes the current authenticated bearer session.
|
||||
*/
|
||||
export async function logoutCurrentSession(): Promise<void> {
|
||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||
method: 'POST',
|
||||
});
|
||||
if (!response.ok && response.status !== 401) {
|
||||
const detail = await responseErrorDetail(response);
|
||||
if (detail) {
|
||||
throw new Error(detail);
|
||||
}
|
||||
throw new Error('Failed to logout');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads documents from the backend list endpoint.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,53 @@
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.auth-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(430px, 100%);
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-3);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(180deg, rgba(28, 42, 63, 0.95) 0%, rgba(20, 30, 47, 0.95) 100%);
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.auth-card h1 {
|
||||
margin: 0;
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.4rem, 2.1vw, 2rem);
|
||||
}
|
||||
|
||||
.auth-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.auth-form label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.auth-form button {
|
||||
margin-top: 0.25rem;
|
||||
min-height: 2.1rem;
|
||||
}
|
||||
|
||||
.app-shell > * {
|
||||
animation: rise-in 220ms ease both;
|
||||
}
|
||||
@@ -57,6 +104,7 @@
|
||||
}
|
||||
|
||||
.topbar-nav-group,
|
||||
.topbar-auth-group,
|
||||
.topbar-document-group,
|
||||
.topbar-settings-group {
|
||||
display: flex;
|
||||
@@ -65,6 +113,22 @@
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.topbar-auth-group {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auth-user-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border-radius: var(--radius-xs);
|
||||
border: 1px solid rgba(108, 135, 184, 0.7);
|
||||
background: rgba(17, 28, 44, 0.85);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.74rem;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.topbar-document-group .upload-actions-inline {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
|
||||
@@ -58,6 +58,31 @@ export interface SearchResponse {
|
||||
items: DmsDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents one authenticated user identity returned by backend auth endpoints.
|
||||
*/
|
||||
export interface AuthUser {
|
||||
id: string;
|
||||
username: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents active authentication session metadata.
|
||||
*/
|
||||
export interface AuthSessionInfo {
|
||||
user: AuthUser;
|
||||
expires_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents login response payload with issued bearer token and session metadata.
|
||||
*/
|
||||
export interface AuthLoginResponse extends AuthSessionInfo {
|
||||
access_token: string;
|
||||
token_type: 'bearer';
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents distinct document type values available for filter controls.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user