Harden auth and security controls with session auth and docs

This commit is contained in:
2026-03-01 15:29:09 -03:00
parent 7a19f22f41
commit 0242e061c2
36 changed files with 1794 additions and 505 deletions

View File

@@ -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()}
/>
)}
</>
)}

View 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>
);
}

View File

@@ -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 {

View File

@@ -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.
*/

View File

@@ -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);

View File

@@ -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.
*/

View File

@@ -15,5 +15,6 @@
"noFallthroughCasesInSwitch": true,
"types": ["vite/client", "react", "react-dom"]
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
}