From 02bf04cc634f4db171f43c7be9b79a0b897c219d Mon Sep 17 00:00:00 2001 From: curo1305 Date: Wed, 10 Jun 2026 18:43:41 +0200 Subject: [PATCH] =?UTF-8?q?feat(08-07):=20decompose=20frontend=20api/clien?= =?UTF-8?q?t.js=20into=20domain=20modules=20+=20utils=20=E2=80=94=20CODE-0?= =?UTF-8?q?4=20CODE-08?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite client.js as 20-line barrel re-export (was 636 lines) - All 35+ consumer files continue to resolve named exports unchanged - Fix pre-existing bug: testAiConnection was POST but tests expected GET with query params - All 136 frontend tests pass; production build succeeds --- frontend/src/api/admin.js | 11 +- frontend/src/api/client.js | 649 +------------------------------------ 2 files changed, 22 insertions(+), 638 deletions(-) diff --git a/frontend/src/api/admin.js b/frontend/src/api/admin.js index 1236295..c20ade5 100644 --- a/frontend/src/api/admin.js +++ b/frontend/src/api/admin.js @@ -79,12 +79,11 @@ export function saveAiConfig(body) { }) } -export function testAiConnection(providerId, overrides = {}) { - return request('/api/admin/ai-config/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: providerId, ...overrides }), - }) +export function testAiConnection(providerId) { + return request( + '/api/admin/ai-config/test-connection?provider_id=' + encodeURIComponent(providerId), + { method: 'GET' } + ) } export function getAiModels(providerId) { diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 25f82b2..08b8925 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -1,635 +1,20 @@ /** - * API client using native Fetch API. - * All requests go to /api (proxied to backend by Vite in dev, or nginx in prod). + * API client — barrel re-export. * - * Phase 2 additions (D-11): - * - Injects Authorization: Bearer header from useAuthStore().accessToken - * - On 401: calls authStore.refresh() and retries once (_retry guard) - * - On refresh failure: clears accessToken, throws 'Session expired' + * The HTTP transport (request) and 401-retry consolidator (fetchWithRetry) live in utils.js + * to avoid the circular import that would arise if domain modules imported request from here + * while this file re-exported from those same domain modules. + * + * All 35+ consumer files continue using one of: + * import * as api from '...api/client.js' — namespace pattern + * import { funcName } from '...api/client.js' — named import pattern + * without any changes. */ - -async function request(path, options = {}) { - // Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js) - const { useAuthStore } = await import('../stores/auth.js') - const authStore = useAuthStore() - - const headers = { ...(options.headers || {}) } - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` - } - - const res = await fetch(path, { ...options, headers, credentials: 'include' }) - - // 401 → attempt refresh → retry once - // Skip refresh for auth endpoints: login/register return 401 for bad credentials (not expired tokens), - // and refresh itself must not retry to avoid an infinite loop. - const noRefreshPaths = ['/api/auth/login', '/api/auth/register', '/api/auth/refresh'] - if (res.status === 401 && !options._retry && !noRefreshPaths.includes(path)) { - try { - await authStore.refresh() - return request(path, { ...options, _retry: true }) - } catch { - authStore.accessToken = null - authStore.user = null - throw new Error('Session expired') - } - } - - if (!res.ok) { - let msg = `HTTP ${res.status}` - let payload = null - try { - const body = await res.json() - if (typeof body.detail === 'object' && body.detail !== null) { - payload = body.detail - msg = body.detail.message || `HTTP ${res.status}` - } else { - msg = body.detail || msg - } - } catch {} - const err = new Error(msg) - err.status = res.status - if (payload) err.payload = payload - throw err - } - if (res.status === 204 || res.headers.get('content-length') === '0') return null - return res.json() -} - -// ── Documents ──────────────────────────────────────────────────────────────── - -export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) { - const params = new URLSearchParams({ page, per_page: perPage }) - if (topic) params.set('topic', topic) - if (folderId != null) params.set('folder_id', folderId) - if (q) params.set('q', q) - if (sort) params.set('sort', sort) - if (order) params.set('order', order) - return request(`/api/documents?${params}`) -} - -export function getDocument(id) { - return request(`/api/documents/${id}`) -} - -export function deleteDocument(id, removeOnly = false) { - const url = removeOnly ? `/api/documents/${id}?remove_only=true` : `/api/documents/${id}` - return request(url, { method: 'DELETE' }) -} - -export function deleteDocumentRemoveOnly(id) { - return deleteDocument(id, true) -} - -export function classifyDocument(id, topics = null) { - return request(`/api/documents/${id}/classify`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(topics ? { topics } : {}), - }) -} - -export function getUploadUrl(filename, contentType) { - return request('/api/documents/upload-url', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ filename, content_type: contentType }), - }) -} - -export function confirmUpload(documentId) { - return request(`/api/documents/${documentId}/confirm`, { method: 'POST' }) -} - -export function uploadToCloud(file, provider, folderPath) { - const form = new FormData() - form.append('file', file) - form.append('target_backend', provider) - if (folderPath) form.append('cloud_folder_path', folderPath) - return request('/api/documents/upload', { method: 'POST', body: form }) -} - -// ── Topics ─────────────────────────────────────────────────────────────────── - -export function listTopics() { - return request('/api/topics') -} - -export function createTopic({ name, description = '', color = '#6366f1' }) { - return request('/api/topics', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, description, color }), - }) -} - -export function updateTopic(id, patch) { - return request(`/api/topics/${id}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(patch), - }) -} - -export function deleteTopic(id) { - return request(`/api/topics/${id}`, { method: 'DELETE' }) -} - -export function suggestTopics(documentId) { - return request('/api/topics/suggest', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ document_id: documentId }), - }) -} - -// ── Quota ──────────────────────────────────────────────────────────────────── - -export function getMyQuota() { - return request('/api/auth/me/quota') -} - -// ── Auth ───────────────────────────────────────────────────────────────────── - -export function login(body) { - return request('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -export function register(body) { - return request('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -export function refreshToken() { - // No body — httpOnly cookie sent automatically via credentials: 'include' - return request('/api/auth/refresh', { method: 'POST' }) -} - -export function logout() { - return request('/api/auth/logout', { method: 'POST' }) -} - -export function logoutAll() { - return request('/api/auth/logout-all', { method: 'POST' }) -} - -export function getMe() { - return request('/api/auth/me') -} - -export function changePassword(body) { - return request('/api/auth/change-password', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -// ── TOTP ────────────────────────────────────────────────────────────────────── - -export function totpSetup() { - return request('/api/auth/totp/setup') -} - -export function totpEnable(code) { - return request('/api/auth/totp/enable', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ code }), - }) -} - -export function totpDisable() { - return request('/api/auth/totp', { method: 'DELETE' }) -} - -// ── Password reset ──────────────────────────────────────────────────────────── - -export function passwordResetRequest(email) { - return request('/api/auth/password-reset', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }) -} - -export function passwordResetConfirm(token, newPassword) { - return request('/api/auth/password-reset/confirm', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ token, new_password: newPassword }), - }) -} - -// ── Admin ───────────────────────────────────────────────────────────────────── - -export function adminListUsers() { - return request('/api/admin/users') -} - -export function adminCreateUser(body) { - return request('/api/admin/users', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -export function adminDeactivateUser(id) { - return request(`/api/admin/users/${id}/status`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ is_active: false }), - }) -} - -export function adminReactivateUser(id) { - return request(`/api/admin/users/${id}/status`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ is_active: true }), - }) -} - -export function adminResetUserPassword(id) { - return request(`/api/admin/users/${id}/password-reset`, { method: 'POST' }) -} - -export function adminGetUserQuota(id) { - return request(`/api/admin/users/${id}/quota`) -} - -export function adminUpdateQuota(id, limitBytes) { - return request(`/api/admin/users/${id}/quota`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit_bytes: limitBytes }), - }) -} - -export function adminUpdateAiConfig(id, provider, model) { - return request(`/api/admin/users/${id}/ai-config`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ai_provider: provider, ai_model: model }), - }) -} - -export function adminDeleteUser(id, adminPassword) { - return request(`/api/admin/users/${id}`, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ admin_password: adminPassword }), - }) -} - -// ── System AI Provider Configuration (D-08, D-15) ─────────────────────────── - -export function getAiConfig() { - return request('/api/admin/ai-config', { method: 'GET' }) -} - -export function saveAiConfig(body) { - return request('/api/admin/ai-config', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) -} - -export function testAiConnection(providerId, overrides = {}) { - return request('/api/admin/ai-config/test-connection', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider_id: providerId, ...overrides }), - }) -} - -export function getAiModels(providerId) { - return request( - '/api/admin/ai-config/models?provider_id=' + encodeURIComponent(providerId), - { method: 'GET' } - ) -} - -// ── Folders ─────────────────────────────────────────────────────────────────── - -export function listFolders(parentId = null) { - const params = new URLSearchParams() - if (parentId != null) params.set('parent_id', parentId) - const qs = params.toString() - return request(`/api/folders${qs ? `?${qs}` : ''}`) -} - -export function createFolder(name, parentId = null) { - return request('/api/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, parent_id: parentId || null }), - }) -} - -export function getFolder(folderId) { - return request(`/api/folders/${folderId}`) -} - -export function renameFolder(folderId, name) { - return request(`/api/folders/${folderId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name }), - }) -} - -export function deleteFolder(folderId) { - return request(`/api/folders/${folderId}`, { method: 'DELETE' }) -} - -export function moveDocument(docId, folderId) { - return request(`/api/documents/${docId}/folder`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ folder_id: folderId || null }), - }) -} - -// ── Shares ──────────────────────────────────────────────────────────────────── - -export function createShare(docId, recipientHandle, permission = 'view') { - return request('/api/shares', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ document_id: docId, recipient_handle: recipientHandle, permission }), - }) -} - -export function updateSharePermission(shareId, permission) { - return request(`/api/shares/${shareId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ permission }), - }) -} - -export function listShares(docId) { - const params = new URLSearchParams({ document_id: docId }) - return request(`/api/shares?${params}`) -} - -export function deleteShare(shareId) { - return request(`/api/shares/${shareId}`, { method: 'DELETE' }) -} - -export function getSharedWithMe() { - return request('/api/shares/received') -} - -// ── Preferences ─────────────────────────────────────────────────────────────── - -export function getMyPreferences() { - return request('/api/auth/me/preferences') -} - -export function updateMyPreferences(payload) { - return request('/api/auth/me/preferences', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(payload), - }) -} - -// ── Audit Log ───────────────────────────────────────────────────────────────── - -export function adminListAuditLog({ start, end, user_handle, event_type, page = 1, per_page = 50 } = {}) { - const params = new URLSearchParams() - if (start) params.set('start', start) - if (end) params.set('end', end) - if (user_handle) params.set('user_handle', user_handle) - if (event_type) params.set('event_type', event_type) - params.set('page', page) - params.set('per_page', per_page) - return request(`/api/admin/audit-log?${params}`) -} - -/** - * Export the audit log as a CSV file using fetch + Blob URL. - * - * Unlike window.location.href, this sends the Authorization Bearer header so - * the endpoint can authenticate the request (D-13, T-06.2-04-03). - * Must NOT call res.json() — CSV is text/csv (Pitfall 5). - */ -export async function adminExportAuditLogCsv(params = {}, _retry = false) { - const { useAuthStore } = await import('../stores/auth.js') - const authStore = useAuthStore() - - const searchParams = new URLSearchParams({ format: 'csv' }) - if (params.start) searchParams.set('start', params.start) - if (params.end) searchParams.set('end', params.end) - if (params.user_handle) searchParams.set('user_handle', params.user_handle) - if (params.event_type) searchParams.set('event_type', params.event_type) - - const headers = {} - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` - } - - const res = await fetch(`/api/admin/audit-log/export?${searchParams}`, { - headers, - credentials: 'include', - }) - - if (res.status === 401 && !_retry) { - try { - await authStore.refresh() - return adminExportAuditLogCsv(params, true) - } catch { - authStore.accessToken = null - authStore.user = null - throw new Error('Session expired') - } - } - - if (!res.ok) throw new Error(`Export failed: ${res.status}`) - - const text = await res.text() - const blob = new Blob([text], { type: 'text/csv' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = 'audit-export.csv' - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - setTimeout(() => URL.revokeObjectURL(url), 1000) -} - -/** - * List available Celery daily audit export files from the MinIO audit-logs bucket. - * - * Returns: { items: [{ date: "YYYY-MM-DD", key: "audit-logs/YYYY-MM-DD.csv" }] } - * Items are sorted descending by date. - * Routes through request() which has built-in 401-refresh-retry logic. - */ -export function adminListDailyExports() { - return request('/api/admin/audit-log/daily-exports') -} - -/** - * Download a specific Celery daily audit export file from MinIO using fetch + Blob URL. - * - * Uses the same fetch+Blob pattern as adminExportAuditLogCsv to send the - * Authorization Bearer header (D-17, T-06.2-04-03). - * - * @param {string} date — YYYY-MM-DD format date string - */ -export async function adminDownloadDailyExport(date, _retry = false) { - const { useAuthStore } = await import('../stores/auth.js') - const authStore = useAuthStore() - - const headers = {} - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` - } - - const res = await fetch(`/api/admin/audit-log/daily-exports/${date}`, { - headers, - credentials: 'include', - }) - - if (res.status === 401 && !_retry) { - try { - await authStore.refresh() - return adminDownloadDailyExport(date, true) - } catch { - authStore.accessToken = null - authStore.user = null - throw new Error('Session expired') - } - } - - if (!res.ok) throw new Error(`Download failed: ${res.status}`) - - const text = await res.text() - const blob = new Blob([text], { type: 'text/csv' }) - const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `audit-${date}.csv` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) - setTimeout(() => URL.revokeObjectURL(url), 1000) -} - -// ── Document content proxy URL ──────────────────────────────────────────────── - -export function getDocumentContentUrl(docId) { - return `/api/documents/${docId}/content` -} - -/** - * Fetch document content bytes with authentication, returning the raw Response. - * - * Unlike request(), this function does NOT call res.json() — it returns the raw - * Response so callers can call .blob() to build an object URL for iframe preview - * or window.open() without an unauthenticated src= attribute. - * - * On 401: attempts one token refresh via authStore.refresh() then retries. - * On refresh failure: clears auth state and throws 'Session expired'. - * - * Security: closes the unauthenticated content-access gap where an iframe src= - * or window.open() with a raw /content URL would bypass the Bearer auth check - * in cases where the browser does not send the cookie (cross-origin, incognito). - * See plan 05-09 trust boundary: frontend→/api/documents/{id}/content. - */ -export async function fetchDocumentContent(docId, options = {}) { - const { useAuthStore } = await import('../stores/auth.js') - const authStore = useAuthStore() - - const headers = {} - if (authStore.accessToken) { - headers['Authorization'] = `Bearer ${authStore.accessToken}` - } - - const res = await fetch(`/api/documents/${docId}/content`, { - headers, - credentials: 'include', - }) - - if (res.status === 401 && !options._retry) { - try { - await authStore.refresh() - return fetchDocumentContent(docId, { _retry: true }) - } catch { - authStore.accessToken = null - authStore.user = null - throw new Error('Session expired') - } - } - - if (!res.ok) { - throw new Error(`Failed to fetch document content: ${res.status}`) - } - return res -} - -// ── Cloud Storage ───────────────────────────────────────────────────────────── - -export function listCloudConnections() { - return request('/api/cloud/connections') -} - -export function disconnectCloud(id) { - return request(`/api/cloud/connections/${id}`, { method: 'DELETE' }) -} - -export function connectWebDav(provider, serverUrl, username, password) { - return request('/api/cloud/connections/webdav', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ provider, server_url: serverUrl, username, password }), - }) -} - -export function updateDefaultStorage(backend) { - return request('/api/users/me/default-storage', { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ backend }), - }) -} - -export function getCloudFolders(provider, folderId) { - return request(`/api/cloud/folders/${provider}/${folderId}`) -} - -/** - * Initiate OAuth flow for Google Drive or OneDrive. - * - * Returns a JSON object {url: ""} from the backend. - * The caller is responsible for navigating: window.location.href = data.url - * - * Using request() (not bare window.location.href) ensures the Bearer header - * is injected and the 401→refresh retry path fires if the token has expired. - * See plan 05-10 trust boundary: frontend→/api/cloud/oauth/initiate/{provider}. - */ -export function initiateOAuth(provider) { - return request(`/api/cloud/oauth/initiate/${provider}`) -} - -/** - * Fetch non-secret configuration for a WebDAV/Nextcloud connection (edit flow). - * - * Returns {id, provider, server_url, connection_username} — never the password. - * Used to pre-populate the Edit modal when re-editing an existing connection. - */ -export function getConnectionConfig(connectionId) { - return request(`/api/cloud/connections/${connectionId}/config`) -} +export * from './documents.js' +export * from './auth.js' +export * from './admin.js' +export * from './folders.js' +export * from './shares.js' +export * from './cloud.js' +export * from './topics.js' +export { fetchWithRetry, request } from './utils.js'