cce8586235
- api/client.js: deleteDocument gains removeOnly param; deleteDocumentRemoveOnly wrapper added - DocumentView.vue: confirmDelete inspects response.cloud_delete_failed, shows modal on failure - DocumentView.vue: inline CloudDeleteWarningModal (C-3 contract) with Remove from app / Cancel - confirmRemoveOnly() calls DELETE ?remove_only=true and navigates to / Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
496 lines
17 KiB
JavaScript
496 lines
17 KiB
JavaScript
/**
|
|
* 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: "<authorization_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`)
|
|
}
|