From 6f3ff1e9a20598f8da229a67dc3d63f7adbdf2fe Mon Sep 17 00:00:00 2001 From: oabrivard Date: Sun, 22 Mar 2026 12:49:00 +0100 Subject: [PATCH] docs: add JSDoc to all frontend API modules, pages, components, utilities Add English JSDoc documentation to 32 source files across the frontend: - API layer (8 files): client CSRF strategy, credential handling, 401 redirect, and endpoint-level docs for auth, settings, sources, syntheses, admin, config, apiKeys - Pages (11 files): Settings export/import, GenerateSynthesis SSE state machine, Home delete confirmation timer, Sources bulk import parsing, SynthesisDetail email/export flows, Login/Register Turnstile lifecycle, AuthVerify token flow, admin Providers/RateLimits/Users - Components (8 files): ApiKeyManager CRUD, Turnstile polling init, Navbar/MobileMenu route detection, Layout/AdminLayout structure, ErrorBoundary retry, Button variants, Toast auto-dismiss timer, LoadingSpinner props - Utilities (2 files): SSE reconnection backoff, dates locale config - Context (1 file): AuthContext session check, isAdmin derived signal No logic changes. TypeScript and vitest pass unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/api/admin.ts | 11 +++++++++ frontend/src/api/apiKeys.ts | 6 +++++ frontend/src/api/auth.ts | 6 +++++ frontend/src/api/client.ts | 24 +++++++++++++++++++ frontend/src/api/config.ts | 2 ++ frontend/src/api/settings.ts | 3 +++ frontend/src/api/sources.ts | 7 ++++++ frontend/src/api/syntheses.ts | 9 +++++++ frontend/src/components/AdminLayout.tsx | 7 ++++++ frontend/src/components/ApiKeyManager.tsx | 9 +++++++ frontend/src/components/ErrorBoundary.tsx | 7 ++++++ frontend/src/components/Layout.tsx | 4 ++++ frontend/src/components/MobileMenu.tsx | 9 +++++++ frontend/src/components/Navbar.tsx | 9 +++++++ frontend/src/components/Turnstile.tsx | 14 +++++++++++ frontend/src/components/ui/Button.tsx | 10 ++++++++ frontend/src/components/ui/LoadingSpinner.tsx | 8 +++++++ frontend/src/components/ui/Toast.tsx | 11 +++++++++ frontend/src/contexts/AuthContext.tsx | 13 ++++++++++ frontend/src/pages/AuthVerify.tsx | 9 +++++++ frontend/src/pages/GenerateSynthesis.tsx | 16 +++++++++++++ frontend/src/pages/Home.tsx | 9 +++++++ frontend/src/pages/Login.tsx | 10 ++++++++ frontend/src/pages/Register.tsx | 9 +++++++ frontend/src/pages/Settings.tsx | 17 +++++++++++++ frontend/src/pages/Sources.tsx | 13 ++++++++++ frontend/src/pages/SynthesisDetail.tsx | 12 ++++++++++ frontend/src/pages/admin/Providers.tsx | 11 +++++++++ frontend/src/pages/admin/RateLimits.tsx | 7 ++++++ frontend/src/pages/admin/Users.tsx | 9 +++++++ frontend/src/utils/dates.ts | 6 +++++ frontend/src/utils/sse.ts | 23 ++++++++++++++++++ 32 files changed, 320 insertions(+) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index d9245e7..d3589a6 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -9,32 +9,43 @@ import type { UpdateUserRoleRequest, } from '~/types'; +/** Admin AI-provider CRUD endpoints. */ export const adminProvidersApi = { + /** GET /admin/providers -- list all configured AI providers. */ list: (): Promise => api.get('/admin/providers'), + /** POST /admin/providers -- register a new AI provider with its models. */ create: (data: CreateProviderRequest): Promise => api.post('/admin/providers', data), + /** PUT /admin/providers/:id -- update display name, models, or enabled state. */ update: (id: string, data: UpdateProviderRequest): Promise => api.put(`/admin/providers/${id}`, data), + /** DELETE /admin/providers/:id -- permanently remove a provider. */ delete: (id: string): Promise => api.delete(`/admin/providers/${id}`), }; +/** Admin rate-limit configuration endpoints. */ export const adminRateLimitsApi = { + /** GET /admin/rate-limits -- list per-provider rate-limit configs. */ list: (): Promise => api.get('/admin/rate-limits'), + /** PUT /admin/rate-limits/:id -- update max requests / time window for a provider. */ update: (id: string, data: UpdateRateLimitRequest): Promise => api.put(`/admin/rate-limits/${id}`, data), }; +/** Admin user management endpoints. */ export const adminUsersApi = { + /** GET /admin/users -- list all registered users. */ list: (): Promise => api.get('/admin/users'), + /** PUT /admin/users/:id/role -- promote or demote a user (admin/user). */ updateRole: (id: string, data: UpdateUserRoleRequest): Promise => api.put(`/admin/users/${id}/role`, data), }; diff --git a/frontend/src/api/apiKeys.ts b/frontend/src/api/apiKeys.ts index 9618c6b..eacce63 100644 --- a/frontend/src/api/apiKeys.ts +++ b/frontend/src/api/apiKeys.ts @@ -1,19 +1,25 @@ import { api } from './client'; import type { UserApiKey, CreateApiKeyRequest, TestApiKeyResponse } from '~/types'; +/** User API-key management endpoints (BYOK -- bring your own key). */ export const apiKeysApi = { + /** GET /user/api-keys -- list stored API keys (returns masked prefixes). */ list: (): Promise => api.get('/user/api-keys'), + /** POST /user/api-keys -- store or replace an API key for a provider. */ create: (data: CreateApiKeyRequest): Promise => api.post('/user/api-keys', data), + /** DELETE /user/api-keys/:provider -- remove the stored key for a provider. */ remove: (provider: string): Promise => api.delete(`/user/api-keys/${encodeURIComponent(provider)}`), + /** POST /user/api-keys/:provider/test -- validate the stored key with a live API call. */ test: (provider: string): Promise => api.post(`/user/api-keys/${encodeURIComponent(provider)}/test`), + /** POST /user/api-keys/export -- return all keys in cleartext (for settings export). */ exportKeys: (): Promise<{ provider_name: string; api_key: string }[]> => api.post('/user/api-keys/export'), }; diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index d8a07c3..a7faf1d 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -8,17 +8,23 @@ import type { VerifyResponse, } from '~/types'; +/** Authentication API endpoints. */ export const authApi = { + /** POST /auth/register -- create a new account and send a verification email. */ register: (data: RegisterRequest): Promise => api.post('/auth/register', data), + /** POST /auth/login -- request a magic-link email for passwordless sign-in. */ login: (data: LoginRequest): Promise => api.post('/auth/login', data), + /** POST /auth/verify -- exchange a magic-link token for a session cookie. */ verify: (token: string): Promise => api.post('/auth/verify', { token }), + /** POST /auth/logout -- invalidate the current session. */ logout: (): Promise => api.post('/auth/logout'), + /** GET /auth/me -- return the currently authenticated user. */ me: (): Promise => api.get('/auth/me'), }; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 84f4938..24bc90a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -2,7 +2,26 @@ import type { ApiError } from '~/types'; const API_BASE = '/api/v1'; +/** + * Centralized HTTP client for all API communication. + * + * Every request includes the `X-Requested-With: XMLHttpRequest` header as a + * lightweight CSRF mitigation: the backend rejects requests that lack this + * header, which browsers won't add on cross-origin form submissions. + * + * Credentials are sent with `same-origin` policy so the session cookie is + * attached automatically without exposing it to third-party origins. + * + * On a 401 response the client redirects the browser to `/login`, ensuring + * expired sessions are handled uniformly across the application. + */ class ApiClient { + /** + * Execute an HTTP request and return the parsed JSON response. + * + * Handles JSON serialization, FormData pass-through, 401 redirects, + * and 204 (no-content) responses. + */ private async request( method: string, path: string, @@ -59,21 +78,26 @@ class ApiClient { return response.json(); } + /** Send a GET request. Accepts an optional `AbortSignal` for cancellation. */ get(path: string, signal?: AbortSignal): Promise { return this.request('GET', path, { signal }); } + /** Send a POST request with an optional JSON or FormData body. */ post(path: string, body?: unknown): Promise { return this.request('POST', path, { body }); } + /** Send a PUT request with an optional JSON body. */ put(path: string, body?: unknown): Promise { return this.request('PUT', path, { body }); } + /** Send a DELETE request. */ delete(path: string): Promise { return this.request('DELETE', path); } } +/** Singleton API client instance used by all API modules. */ export const api = new ApiClient(); diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 82c92e9..518c40b 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -1,7 +1,9 @@ import { api } from './client'; import type { ProviderConfig } from '~/types'; +/** Public configuration endpoints (no admin role required). */ export const configApi = { + /** GET /config/providers -- list enabled AI providers and their models visible to users. */ listProviders: (): Promise => api.get('/config/providers'), }; diff --git a/frontend/src/api/settings.ts b/frontend/src/api/settings.ts index c140edf..aca619b 100644 --- a/frontend/src/api/settings.ts +++ b/frontend/src/api/settings.ts @@ -1,9 +1,12 @@ import { api } from './client'; import type { UserSettings } from '~/types'; +/** User settings API endpoints. */ export const settingsApi = { + /** GET /settings -- retrieve the current user's settings (404 if none saved yet). */ get: (): Promise => api.get('/settings'), + /** PUT /settings -- create or fully replace the current user's settings. */ update: (settings: UserSettings): Promise => api.put('/settings', settings), }; diff --git a/frontend/src/api/sources.ts b/frontend/src/api/sources.ts index d1c9af3..3eeb2ba 100644 --- a/frontend/src/api/sources.ts +++ b/frontend/src/api/sources.ts @@ -7,23 +7,30 @@ import type { BulkImportResponse, } from '~/types'; +/** Custom sources API endpoints (user-curated URLs for the AI to prioritize). */ export const sourcesApi = { + /** GET /sources -- list all sources belonging to the current user. */ list: (): Promise => api.get('/sources'), + /** POST /sources -- add a single custom source. */ create: (data: CreateSourceRequest): Promise => api.post('/sources', data), + /** DELETE /sources/:id -- remove a source by ID. */ remove: (id: string): Promise => api.delete(`/sources/${id}`), + /** POST /sources/bulk -- import multiple sources from a JSON array. */ bulkImport: (data: BulkImportRequest): Promise => api.post('/sources/bulk', data), + /** POST /sources/import-csv -- import sources from an uploaded CSV file. */ importCsv: async (file: File): Promise => { const formData = new FormData(); formData.append('file', file); return api.post('/sources/import-csv', formData); }, + /** GET /sources/export-csv -- download all sources as a CSV file. */ exportCsv: async (): Promise => { const response = await fetchFile('/sources/export-csv'); await triggerDownload(response, 'sources.csv'); diff --git a/frontend/src/api/syntheses.ts b/frontend/src/api/syntheses.ts index 192e2d1..048f35c 100644 --- a/frontend/src/api/syntheses.ts +++ b/frontend/src/api/syntheses.ts @@ -57,30 +57,39 @@ export async function fetchFile(path: string): Promise { return response; } +/** Synthesis API endpoints (CRUD, generation, export, email). */ export const synthesesApi = { + /** GET /syntheses -- paginated list of the user's syntheses. */ list: (limit = 50, offset = 0): Promise => api.get(`/syntheses?limit=${limit}&offset=${offset}`), + /** GET /syntheses/:id -- fetch a single synthesis with full content. */ get: (id: string): Promise => api.get(`/syntheses/${id}`), + /** DELETE /syntheses/:id -- permanently delete a synthesis. */ remove: (id: string): Promise => api.delete(`/syntheses/${id}`), + /** POST /syntheses/generate -- kick off an async generation job, returns a job ID. */ generate: (): Promise => api.post('/syntheses/generate'), + /** Build the SSE endpoint URL for streaming generation progress. */ progressUrl: (jobId: string): string => `${API_BASE}/syntheses/generate/${jobId}/progress`, + /** POST /syntheses/:id/send-email -- email the synthesis to the given address. */ sendEmail: (id: string, email: string): Promise => api.post(`/syntheses/${id}/send-email`, { email } satisfies SendEmailRequest), + /** Download the synthesis as a Markdown file via {@link fetchFile} + {@link triggerDownload}. */ exportMarkdown: async (id: string): Promise => { const response = await fetchFile(`/syntheses/${id}/export/markdown`); await triggerDownload(response, `synthese-${id}.md`); }, + /** Download the synthesis as a PDF file via {@link fetchFile} + {@link triggerDownload}. */ exportPdf: async (id: string): Promise => { const response = await fetchFile(`/syntheses/${id}/export/pdf`); await triggerDownload(response, `synthese-${id}.pdf`); diff --git a/frontend/src/components/AdminLayout.tsx b/frontend/src/components/AdminLayout.tsx index f148d7d..c7b2c11 100644 --- a/frontend/src/components/AdminLayout.tsx +++ b/frontend/src/components/AdminLayout.tsx @@ -4,6 +4,13 @@ import { BrainCircuit, Shield, Server, Gauge, Users, LogOut, Menu, X } from 'luc import { useAuth } from '~/contexts/AuthContext'; import { useI18n } from '~/i18n'; +/** + * Admin area layout with a fixed sidebar navigation and responsive mobile drawer. + * + * The sidebar links (Providers, Rate Limits, Users) use active-route detection + * to highlight the current page. Only users with the `admin` role can reach + * routes using this layout (enforced by the router guard). + */ const AdminLayout: Component<{ children: any }> = (props) => { const { user, logout } = useAuth(); const location = useLocation(); diff --git a/frontend/src/components/ApiKeyManager.tsx b/frontend/src/components/ApiKeyManager.tsx index 84488ac..d3e8601 100644 --- a/frontend/src/components/ApiKeyManager.tsx +++ b/frontend/src/components/ApiKeyManager.tsx @@ -16,6 +16,14 @@ interface ApiKeyManagerProps { providers: ProviderConfig[]; } +/** + * Manages per-provider API keys (BYOK) displayed on the Settings page. + * + * Renders one {@link ProviderKeyCard} per configured provider. The card + * supports creating, testing, and deleting keys, as well as toggling + * key visibility (show/hide). The `test` button makes a live validation + * call to verify the key works. + */ const ApiKeyManager: Component = (props) => { const { t } = useI18n(); const { addToast } = useToast(); @@ -61,6 +69,7 @@ interface ProviderKeyCardProps { onKeyChanged: () => void; } +/** Individual card for a single provider's API key with CRUD and test actions. */ const ProviderKeyCard: Component = (props) => { const { t } = useI18n(); const { addToast } = useToast(); diff --git a/frontend/src/components/ErrorBoundary.tsx b/frontend/src/components/ErrorBoundary.tsx index 3dd3db9..59fe788 100644 --- a/frontend/src/components/ErrorBoundary.tsx +++ b/frontend/src/components/ErrorBoundary.tsx @@ -3,6 +3,13 @@ import type { ParentComponent } from 'solid-js'; import { AlertTriangle } from 'lucide-solid'; import { useI18n } from '~/i18n'; +/** + * Application-level error boundary wrapping `solid-js` `ErrorBoundary`. + * + * Catches any unhandled exception in the component tree and renders a + * full-page error screen with the error message. The "Retry" button calls + * Solid's `reset()` to re-render the children from scratch. + */ const AppErrorBoundary: ParentComponent = (props) => { const { t } = useI18n(); diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 509c2e5..3fda25c 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -2,6 +2,10 @@ import { type Component, createSignal, Show } from 'solid-js'; import Navbar from './Navbar'; import MobileMenu from './MobileMenu'; +/** + * Top-level page layout: renders the {@link Navbar}, conditionally shows the + * {@link MobileMenu}, and wraps page content in a `
` element. + */ const Layout: Component<{ children: any }> = (props) => { const [mobileMenuOpen, setMobileMenuOpen] = createSignal(false); diff --git a/frontend/src/components/MobileMenu.tsx b/frontend/src/components/MobileMenu.tsx index a73e41f..6b807b0 100644 --- a/frontend/src/components/MobileMenu.tsx +++ b/frontend/src/components/MobileMenu.tsx @@ -8,6 +8,15 @@ interface MobileMenuProps { onClose: () => void; } +/** + * Slide-in mobile navigation menu (visible only on small screens). + * + * - **Escape key handler**: A global `keydown` listener closes the menu + * when Escape is pressed; the listener is registered on mount and removed + * on cleanup. + * - **Backdrop click**: Clicking the semi-transparent overlay behind the + * panel also triggers `onClose`. + */ const MobileMenu: Component = (props) => { const { user, isAdmin, logout } = useAuth(); const location = useLocation(); diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index cbc17e5..34d79c7 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -8,6 +8,15 @@ interface NavbarProps { onToggleMobile: () => void; } +/** + * Top navigation bar visible on all authenticated pages. + * + * - **Active route detection**: The current pathname is compared against each + * link's `href` to apply a highlighted bottom-border style. + * - **Admin link visibility**: The admin navigation link is only rendered + * when `isAdmin()` is true, using a prefix match on `/admin` for the + * active state so all admin sub-routes highlight the same tab. + */ const Navbar: Component = (props) => { const { user, isAdmin, logout } = useAuth(); const location = useLocation(); diff --git a/frontend/src/components/Turnstile.tsx b/frontend/src/components/Turnstile.tsx index 9697e2a..6083967 100644 --- a/frontend/src/components/Turnstile.tsx +++ b/frontend/src/components/Turnstile.tsx @@ -26,8 +26,21 @@ interface TurnstileProps { onError?: () => void; } +/** Turnstile site key loaded from env, with a Cloudflare testing key as fallback. */ const SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY ?? '1x00000000000000000000AA'; +/** + * Cloudflare Turnstile CAPTCHA widget wrapper. + * + * **Widget lifecycle**: + * 1. On mount, if `window.turnstile` is available (script already loaded), + * the widget is rendered immediately. + * 2. Otherwise, a 100ms polling interval waits for the external Turnstile + * script to load and expose the global API, then renders. + * 3. On cleanup (component unmount), the widget is removed via + * `turnstile.remove()` and any polling interval is cleared to prevent + * memory leaks. + */ const Turnstile: Component = (props) => { let containerRef: HTMLDivElement | undefined; let widgetId: string | undefined; @@ -76,6 +89,7 @@ const Turnstile: Component = (props) => { export default Turnstile; +/** Reset an existing Turnstile widget so the user can re-verify. */ export function resetTurnstile(widgetId: string): void { if (window.turnstile) { window.turnstile.reset(widgetId); diff --git a/frontend/src/components/ui/Button.tsx b/frontend/src/components/ui/Button.tsx index 76e61a1..495716c 100644 --- a/frontend/src/components/ui/Button.tsx +++ b/frontend/src/components/ui/Button.tsx @@ -1,5 +1,14 @@ import { type Component, type JSX, Show, splitProps } from 'solid-js'; +/** + * Props for the reusable Button component. + * + * - `primary` (default): indigo background, white text. + * - `secondary`: white background, gray border, dark text. + * - `danger`: red background, white text. + * + * When `loading` is true, the button is disabled and a spinner replaces the icon. + */ interface ButtonProps extends JSX.ButtonHTMLAttributes { variant?: 'primary' | 'secondary' | 'danger'; loading?: boolean; @@ -15,6 +24,7 @@ const variantClasses: Record = { 'text-white bg-red-600 hover:bg-red-700 border-transparent focus:ring-red-500', }; +/** Styled button with variant theming, optional leading icon, and loading state. */ const Button: Component = (allProps) => { const [props, rest] = splitProps(allProps, [ 'variant', diff --git a/frontend/src/components/ui/LoadingSpinner.tsx b/frontend/src/components/ui/LoadingSpinner.tsx index 9d0ea31..faa1af7 100644 --- a/frontend/src/components/ui/LoadingSpinner.tsx +++ b/frontend/src/components/ui/LoadingSpinner.tsx @@ -1,5 +1,12 @@ import type { Component } from 'solid-js'; +/** + * Props for the LoadingSpinner component. + * @property fullPage - When true, the spinner is vertically centered to fill + * the entire viewport height; otherwise it uses a fixed 16rem container. + * @property size - Controls the spinner diameter: `sm` (1rem), `md` (2rem), + * or `lg` (3rem, default). + */ interface LoadingSpinnerProps { fullPage?: boolean; size?: 'sm' | 'md' | 'lg'; @@ -11,6 +18,7 @@ const sizeClasses = { lg: 'h-12 w-12', }; +/** Animated indigo spinner for loading states. */ const LoadingSpinner: Component = (props) => { const sizeClass = () => sizeClasses[props.size ?? 'lg']; diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx index 3ece5b7..e19810b 100644 --- a/frontend/src/components/ui/Toast.tsx +++ b/frontend/src/components/ui/Toast.tsx @@ -10,6 +10,7 @@ import { Portal } from 'solid-js/web'; import { CheckCircle, XCircle, Info, X } from 'lucide-solid'; import { useI18n } from '~/i18n'; +/** A single toast notification. */ interface Toast { id: string; type: 'success' | 'error' | 'info'; @@ -17,6 +18,7 @@ interface Toast { duration: number; } +/** Public API exposed by the toast context. */ interface ToastContextType { addToast: (toast: Omit) => void; removeToast: (id: string) => void; @@ -25,8 +27,16 @@ interface ToastContextType { const ToastContext = createContext(); let nextId = 0; +/** Tracks active auto-dismiss timers so they can be cleared on manual dismiss. */ const activeTimers = new Map>(); +/** + * Context provider that manages a stack of toast notifications. + * + * Toasts are rendered into a portal at the top-right corner. Each toast has + * an auto-dismiss timer (default 5 s) that removes it after `duration` ms. + * Manually dismissing a toast clears its timer to avoid double-removal. + */ export const ToastProvider: ParentComponent = (props) => { const [toasts, setToasts] = createSignal([]); @@ -110,6 +120,7 @@ const ToastItem: Component<{ ); }; +/** Access the toast context. Must be called within a {@link ToastProvider}. */ export const useToast = (): ToastContextType => { const ctx = useContext(ToastContext); if (!ctx) throw new Error('useToast must be used within ToastProvider'); diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx index 97b340f..e11b9bd 100644 --- a/frontend/src/contexts/AuthContext.tsx +++ b/frontend/src/contexts/AuthContext.tsx @@ -3,10 +3,12 @@ import type { ParentComponent } from 'solid-js'; import { authApi } from '~/api/auth'; import type { User } from '~/types'; +/** Public API surface of the authentication context. */ export interface AuthContextType { user: () => User | null; loading: () => boolean; isAuthenticated: () => boolean; + /** Derived signal: true when the user's role is `"admin"`. */ isAdmin: () => boolean; logout: () => Promise; refreshUser: () => Promise; @@ -14,6 +16,16 @@ export interface AuthContextType { const AuthContext = createContext(); +/** + * Global authentication context provider. + * + * On mount, a GET /auth/me call checks for an existing session cookie. + * If the call succeeds the user signal is populated; on 401 (handled by + * the API client) the user remains `null` and `loading` becomes `false`. + * + * `isAdmin` is a derived signal computed from `user()?.role === 'admin'` + * so it stays reactive without extra subscriptions. + */ export const AuthProvider: ParentComponent = (props) => { const [user, setUser] = createSignal(null); const [loading, setLoading] = createSignal(true); @@ -60,6 +72,7 @@ export const AuthProvider: ParentComponent = (props) => { ); }; +/** Access the auth context. Must be called within an {@link AuthProvider}. */ export const useAuth = (): AuthContextType => { const ctx = useContext(AuthContext); if (!ctx) throw new Error('useAuth must be used within AuthProvider'); diff --git a/frontend/src/pages/AuthVerify.tsx b/frontend/src/pages/AuthVerify.tsx index 698d48d..b65f85a 100644 --- a/frontend/src/pages/AuthVerify.tsx +++ b/frontend/src/pages/AuthVerify.tsx @@ -6,6 +6,15 @@ import { useAuth } from '~/contexts/AuthContext'; import { useI18n } from '~/i18n'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +/** + * Magic-link verification page reached via `/auth/verify?token=...`. + * + * On mount, the `token` query parameter is extracted from the URL. If present, + * it is POSTed to the backend's verify endpoint which sets a session cookie. + * On success the auth context is refreshed and the user is redirected home + * after a 1.5-second delay. Missing or invalid tokens display an error with + * a link back to the login page. + */ const AuthVerify: Component = () => { const [searchParams] = useSearchParams(); const navigate = useNavigate(); diff --git a/frontend/src/pages/GenerateSynthesis.tsx b/frontend/src/pages/GenerateSynthesis.tsx index f7c28a1..6ae1d33 100644 --- a/frontend/src/pages/GenerateSynthesis.tsx +++ b/frontend/src/pages/GenerateSynthesis.tsx @@ -18,11 +18,13 @@ import { createSSEConnection, type SSEConnection, type SSEStatus } from '~/utils import { providerSupportsWebSearch } from '~/utils/providers'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +/** Metadata for a single generation pipeline step. */ interface StepInfo { key: string; label: string; } +/** Ordered pipeline steps displayed as a checklist during generation. */ const STEPS: StepInfo[] = [ { key: 'search', label: 'generate.step.search' }, { key: 'scraping', label: 'generate.step.scraping' }, @@ -30,6 +32,20 @@ const STEPS: StepInfo[] = [ { key: 'saving', label: 'generate.step.saving' }, ]; +/** + * Synthesis generation page with real-time progress tracking. + * + * SSE state machine: + * idle -> connecting -> connected (progress events) -> complete | error + * + * 1. User clicks "Generate": a POST creates a job and returns a `job_id`. + * 2. An SSE connection is opened to the progress endpoint for that job. + * 3. `progress` events drive the progress bar and step checklist. + * 4. A `complete` event carries the `synthesis_id`; the page auto-redirects + * after a short delay. An `error` event surfaces the message and stops. + * 5. On connection loss, exponential backoff retries up to 3 times (see + * {@link createSSEConnection}). + */ const GenerateSynthesis: Component = () => { const { t } = useI18n(); const navigate = useNavigate(); diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 214fa48..3b65da6 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -14,6 +14,15 @@ import type { SynthesisListItem } from '~/types'; import { extractWeekNumber, formatDate } from '~/utils/dates'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +/** + * Home / dashboard page listing the user's past syntheses. + * + * Delete confirmation uses a two-click pattern with a 3-second auto-cancel + * timer: the first click on the trash icon puts the card in "confirm" state + * (visually highlighted). A second click within 3 seconds actually deletes. + * If no second click occurs, the state resets automatically via `setTimeout`. + * Timers are stored per-synthesis-id so multiple cards can be independent. + */ const Home: Component = () => { const { t } = useI18n(); diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 463eb8b..ddfa5e6 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -14,6 +14,16 @@ import { isApiError } from '~/types'; import Turnstile from '~/components/Turnstile'; import Button from '~/components/ui/Button'; +/** + * Passwordless login page using magic-link email authentication. + * + * - **Turnstile lifecycle**: The Cloudflare Turnstile widget provides a + * bot-protection token that must be obtained before the form can submit. + * Token expiry or error resets the token signal to `null`. + * - **Resend cooldown**: After a successful login request, a 60-second + * cooldown prevents spamming. The countdown is driven by a `setInterval` + * that decrements every second and self-clears at zero. + */ const Login: Component = () => { const { isAuthenticated, loading: authLoading } = useAuth(); const navigate = useNavigate(); diff --git a/frontend/src/pages/Register.tsx b/frontend/src/pages/Register.tsx index 845c9c1..afddfbe 100644 --- a/frontend/src/pages/Register.tsx +++ b/frontend/src/pages/Register.tsx @@ -14,6 +14,15 @@ import { isApiError } from '~/types'; import Turnstile from '~/components/Turnstile'; import Button from '~/components/ui/Button'; +/** + * Account registration page with magic-link email verification. + * + * - **Turnstile lifecycle**: Identical to the Login page -- a Cloudflare + * Turnstile token is required before submission. Expiry or error resets + * the token to `null`, disabling the submit button. + * - **Resend cooldown**: A 60-second countdown prevents repeated + * registration attempts for the same email. + */ const Register: Component = () => { const { isAuthenticated, loading: authLoading } = useAuth(); const navigate = useNavigate(); diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index d1f7f35..e1fcb19 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -18,6 +18,22 @@ import LoadingSpinner from '~/components/ui/LoadingSpinner'; import ApiKeyManager from '~/components/ApiKeyManager'; import { getProviderInfoKey, providerSupportsWebSearch } from '~/utils/providers'; +/** + * Settings page for configuring the user's synthesis preferences. + * + * Key behaviors: + * - **Export/Import**: Settings can be exported as JSON (optionally including + * cleartext API keys). On import, missing fields are merged with + * `DEFAULT_SETTINGS` so partial files are safe. + * - **Provider auto-detection**: When only one provider is configured, the + * provider dropdown is hidden and that provider is auto-selected. + * - **Rate limit null handling**: The `rate_limit_max_requests` and + * `rate_limit_time_window_seconds` fields accept `null` (meaning "use + * server defaults"). Empty inputs are stored as `null`, not zero. + * - **Dual model state**: Research model (`ai_model`) and writing model + * (`ai_model_writing`) are independently selectable from the same + * provider's model list. + */ const Settings: Component = () => { const { t } = useI18n(); @@ -730,3 +746,4 @@ const Settings: Component = () => { }; export default Settings; + diff --git a/frontend/src/pages/Sources.tsx b/frontend/src/pages/Sources.tsx index c13eef0..bb71b32 100644 --- a/frontend/src/pages/Sources.tsx +++ b/frontend/src/pages/Sources.tsx @@ -49,6 +49,19 @@ export function isValidUrl(url: string): boolean { } } +/** + * Sources management page for adding, deleting, and bulk-importing custom URLs. + * + * Key behaviors: + * - **Bulk import parsing**: The textarea accepts one source per line in + * `title;url` format. Lines are split on the first semicolon; the URL + * portion is normalized via {@link normalizeUrl}. + * - **CSV flow**: Export downloads a CSV via the backend; import uploads a + * `.csv` file as `multipart/form-data`. The file input is reset after + * each import so the same file can be re-selected. + * - **URL normalization**: {@link normalizeUrl} prepends `https://` when no + * scheme is present. {@link isValidUrl} then validates the result. + */ const Sources: Component = () => { const { t } = useI18n(); diff --git a/frontend/src/pages/SynthesisDetail.tsx b/frontend/src/pages/SynthesisDetail.tsx index 56fe774..8b2e791 100644 --- a/frontend/src/pages/SynthesisDetail.tsx +++ b/frontend/src/pages/SynthesisDetail.tsx @@ -15,6 +15,7 @@ import type { Synthesis, NewsItem as NewsItemType } from '~/types'; import { extractWeekNumber, formatDateLong } from '~/utils/dates'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; +/** Renders a single news article with its title (linked) and summary. */ const NewsItemCard: Component<{ item: NewsItemType }> = (props) => { return (
@@ -36,6 +37,7 @@ const NewsItemCard: Component<{ item: NewsItemType }> = (props) => { ); }; +/** Renders a titled category section containing a list of {@link NewsItemCard}s. */ const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => { return ( 0}> @@ -53,6 +55,16 @@ const Section: Component<{ title: string; items: NewsItemType[] }> = (props) => ); }; +/** + * Detail view for a single synthesis, with email send and export capabilities. + * + * - **Email send flow**: The email input is pre-filled from the authenticated + * user's address. On send, a POST is made to the backend; a success banner + * auto-dismisses after 5 seconds. + * - **Export download**: Markdown and PDF exports call {@link fetchFile} to + * obtain a binary Response, then {@link triggerDownload} to create a + * temporary `` element that triggers the browser download dialog. + */ const SynthesisDetail: Component = () => { const { t } = useI18n(); const { user } = useAuth(); diff --git a/frontend/src/pages/admin/Providers.tsx b/frontend/src/pages/admin/Providers.tsx index 74a26f0..dd995ae 100644 --- a/frontend/src/pages/admin/Providers.tsx +++ b/frontend/src/pages/admin/Providers.tsx @@ -14,18 +14,29 @@ import type { AdminProvider, AdminProviderModel } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import Button from '~/components/ui/Button'; +/** Local editable state for an existing provider card. */ interface ProviderFormState { display_name: string; models: AdminProviderModel[]; is_enabled: boolean; } +/** Factory for a blank model row in the form. */ const emptyModel = (): AdminProviderModel => ({ model_id: '', display_name: '', is_default: false, }); +/** + * Admin page for managing AI providers and their models (CRUD). + * + * Each provider is rendered as an editable card with inline model management. + * Local edits are tracked in a `Record` map so unsaved + * changes are preserved while switching between cards. Saving writes to the + * backend and refetches the canonical list. Deletion requires a two-click + * confirmation within the same card footer. + */ const Providers: Component = () => { const { t } = useI18n(); const { addToast } = useToast(); diff --git a/frontend/src/pages/admin/RateLimits.tsx b/frontend/src/pages/admin/RateLimits.tsx index ab5a750..b4fabc0 100644 --- a/frontend/src/pages/admin/RateLimits.tsx +++ b/frontend/src/pages/admin/RateLimits.tsx @@ -15,6 +15,7 @@ import type { AdminRateLimit } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import Button from '~/components/ui/Button'; +/** Local mutable copy of a rate-limit row for editing without modifying the resource. */ interface LocalRateLimit { id: string; provider_name: string; @@ -22,6 +23,12 @@ interface LocalRateLimit { time_window_seconds: number; } +/** + * Admin page for viewing and updating per-provider API rate-limit configuration. + * + * Fetched rate limits are copied into local signals so form inputs can mutate + * freely. Saving PUTs the updated values to the backend and refetches. + */ const RateLimits: Component = () => { const { t } = useI18n(); const { addToast } = useToast(); diff --git a/frontend/src/pages/admin/Users.tsx b/frontend/src/pages/admin/Users.tsx index a1f8c5e..8fd8e1c 100644 --- a/frontend/src/pages/admin/Users.tsx +++ b/frontend/src/pages/admin/Users.tsx @@ -15,6 +15,15 @@ import type { AdminUser } from '~/types'; import LoadingSpinner from '~/components/ui/LoadingSpinner'; import Button from '~/components/ui/Button'; +/** + * Admin user management page with role promotion/demotion. + * + * - **Role management**: Each user row shows a promote/demote button that + * opens a confirmation banner before applying. + * - **Self-demotion guard**: The currently authenticated admin cannot change + * their own role -- the button is replaced with an informational label to + * prevent accidental lock-out. + */ const Users: Component = () => { const { t } = useI18n(); const { user: currentUser } = useAuth(); diff --git a/frontend/src/utils/dates.ts b/frontend/src/utils/dates.ts index e499d3d..15bfd7f 100644 --- a/frontend/src/utils/dates.ts +++ b/frontend/src/utils/dates.ts @@ -1,3 +1,9 @@ +/** + * Date formatting utilities using `date-fns` with the French (`fr`) locale. + * + * All formatters accept ISO 8601 strings and gracefully return the raw input + * on parse failure. + */ import { format, parseISO } from 'date-fns'; import { fr } from 'date-fns/locale'; diff --git a/frontend/src/utils/sse.ts b/frontend/src/utils/sse.ts index 70901a3..d6a1c48 100644 --- a/frontend/src/utils/sse.ts +++ b/frontend/src/utils/sse.ts @@ -1,25 +1,31 @@ import { createSignal, onCleanup } from 'solid-js'; import type { ProgressEvent } from '~/types'; +/** Lifecycle states of an SSE connection. */ export type SSEStatus = 'idle' | 'connecting' | 'connected' | 'complete' | 'error'; +/** Server-sent event indicating the job finished successfully. */ export interface SSECompleteEvent { type: 'complete'; synthesis_id: string; } +/** Server-sent event indicating the job failed. */ export interface SSEErrorEvent { type: 'error'; message: string; } +/** Server-sent event carrying generation progress data. */ export interface SSEProgressEvent { type: 'progress'; data: ProgressEvent; } +/** Union of all SSE event types emitted during synthesis generation. */ export type SSEEvent = SSEProgressEvent | SSECompleteEvent | SSEErrorEvent; +/** Reactive handle returned by {@link createSSEConnection}. */ export interface SSEConnection { events: () => SSEEvent[]; status: () => SSEStatus; @@ -29,9 +35,26 @@ export interface SSEConnection { close: () => void; } +/** Maximum reconnection attempts before giving up. */ const MAX_RETRIES = 3; +/** Initial retry delay in ms; doubled on each subsequent attempt (exponential backoff). */ const BASE_RETRY_DELAY = 1000; +/** + * Open an EventSource connection and expose its state as Solid signals. + * + * **Reconnection backoff**: On connection loss (not a server-sent `error` + * event), the client retries up to {@link MAX_RETRIES} times with exponential + * backoff starting at {@link BASE_RETRY_DELAY} ms (1s, 2s, 4s). + * + * **Event parsing**: The server emits named events (`progress`, `complete`, + * `error`). Each is JSON-parsed and pushed into the `events` signal array. + * Malformed payloads are silently ignored. + * + * **Cleanup**: The EventSource is closed on component unmount (via + * `onCleanup`) or when `close()` is called manually. Any pending retry + * timeout is also cleared. + */ export function createSSEConnection(url: string): SSEConnection { const [events, setEvents] = createSignal([]); const [status, setStatus] = createSignal('idle');