/** * API client using native Fetch API. * All requests go to /api (proxied to backend by Vite in dev, or nginx in prod). * * 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' */ 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 }), }) } // ── 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) { return request(`/api/shares?document_id=${docId}`) } 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_id, 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_id) params.set('user_id', user_id) 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}`) } // ── 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`) }