Refactor backend and frontend cleanup paths

This commit is contained in:
curo1305
2026-06-16 11:50:17 +02:00
parent 6b56763689
commit e97ca164d7
29 changed files with 1106 additions and 2280 deletions
+45 -75
View File
@@ -1,31 +1,42 @@
import { request, fetchWithRetry } from './utils.js'
import { request, fetchWithRetry, jsonRequest } from './utils.js'
function withPresentParams(initial = {}, params = {}) {
const searchParams = new URLSearchParams(initial)
Object.entries(params).forEach(([key, value]) => {
if (value) searchParams.set(key, value)
})
return searchParams
}
async function downloadCsv(url, filename, errorPrefix) {
const res = await fetchWithRetry(url)
if (!res.ok) throw new Error(`${errorPrefix}: ${res.status}`)
const blob = new Blob([await res.text()], { type: 'text/csv' })
const objectUrl = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = objectUrl
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000)
}
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),
})
return jsonRequest('/api/admin/users', 'POST', 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 }),
})
return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { 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 }),
})
return jsonRequest(`/api/admin/users/${id}/status`, 'PATCH', { is_active: true })
}
export function adminResetUserPassword(id) {
@@ -37,27 +48,18 @@ export function adminGetUserQuota(id) {
}
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 }),
})
return jsonRequest(`/api/admin/users/${id}/quota`, 'PATCH', { 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 }),
return jsonRequest(`/api/admin/users/${id}/ai-config`, 'PATCH', {
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 }),
})
return jsonRequest(`/api/admin/users/${id}`, 'DELETE', { admin_password: adminPassword })
}
export function getAiConfig() {
@@ -65,11 +67,7 @@ export function getAiConfig() {
}
export function saveAiConfig(body) {
return request('/api/admin/ai-config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonRequest('/api/admin/ai-config', 'PUT', body)
}
export function testAiConnection(providerId) {
@@ -87,38 +85,23 @@ export function getAiModels(providerId) {
}
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)
const params = withPresentParams({}, { start, end, user_handle, event_type })
params.set('page', page)
params.set('per_page', per_page)
return request(`/api/admin/audit-log?${params}`)
}
// Unlike window.location.href, fetchWithRetry sends the Authorization Bearer header so
// the endpoint can authenticate. Must NOT call res.json() — CSV is text/csv.
export async function adminExportAuditLogCsv(params = {}) {
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 res = await fetchWithRetry(`/api/admin/audit-log/export?${searchParams}`)
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)
const searchParams = withPresentParams(
{ format: 'csv' },
{
start: params.start,
end: params.end,
user_handle: params.user_handle,
event_type: params.event_type,
}
)
return downloadCsv(`/api/admin/audit-log/export?${searchParams}`, 'audit-export.csv', 'Export failed')
}
export function adminListDailyExports() {
@@ -129,19 +112,6 @@ export async function getAdminOverview() {
return request('/api/admin/overview')
}
// Uses fetchWithRetry() to send the Authorization Bearer header (D-17, T-06.2-04-03).
export async function adminDownloadDailyExport(date) {
const res = await fetchWithRetry(`/api/admin/audit-log/daily-exports/${date}`)
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)
return downloadCsv(`/api/admin/audit-log/daily-exports/${date}`, `audit-${date}.csv`, 'Download failed')
}
+10 -35
View File
@@ -5,22 +5,14 @@
* TotpEnrollment.vue, PasswordResetView.vue
*/
import { request } from './utils.js'
import { request, jsonRequest } from './utils.js'
export function login(body) {
return request('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonRequest('/api/auth/login', 'POST', body)
}
export function register(body) {
return request('/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonRequest('/api/auth/register', 'POST', body)
}
export function refreshToken() {
@@ -41,11 +33,7 @@ export function getMe() {
}
export function changePassword(body) {
return request('/api/auth/change-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return jsonRequest('/api/auth/change-password', 'POST', body)
}
export function totpSetup() {
@@ -53,11 +41,7 @@ export function totpSetup() {
}
export function totpEnable(code) {
return request('/api/auth/totp/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
})
return jsonRequest('/api/auth/totp/enable', 'POST', { code })
}
export function totpDisable() {
@@ -65,18 +49,13 @@ export function totpDisable() {
}
export function passwordResetRequest(email) {
return request('/api/auth/password-reset', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
})
return jsonRequest('/api/auth/password-reset', 'POST', { 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 }),
return jsonRequest('/api/auth/password-reset/confirm', 'POST', {
token,
new_password: newPassword,
})
}
@@ -85,11 +64,7 @@ export function getMyPreferences() {
}
export function updateMyPreferences(payload) {
return request('/api/auth/me/preferences', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return jsonRequest('/api/auth/me/preferences', 'PATCH', payload)
}
export function getMyQuota() {
+7 -10
View File
@@ -6,7 +6,7 @@
* CloudFolderTreeItem.vue
*/
import { request } from './utils.js'
import { request, jsonRequest } from './utils.js'
export function listCloudConnections() {
return request('/api/cloud/connections')
@@ -17,19 +17,16 @@ export function disconnectCloud(id) {
}
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 }),
return jsonRequest('/api/cloud/connections/webdav', 'POST', {
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 }),
})
return jsonRequest('/api/users/me/default-storage', 'PATCH', { backend })
}
export function getCloudFolders(provider, folderId) {
+5 -10
View File
@@ -5,7 +5,7 @@
* DocumentPreviewModal.vue, FileManagerView.vue, CloudFolderView.vue
*/
import { request, fetchWithRetry } from './utils.js'
import { request, fetchWithRetry, jsonRequest } from './utils.js'
export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) {
const params = new URLSearchParams({ page, per_page: perPage })
@@ -31,18 +31,13 @@ export function deleteDocumentRemoveOnly(id) {
}
export function classifyDocument(id, topics = null) {
return request(`/api/documents/${id}/classify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(topics ? { topics } : {}),
})
return jsonRequest(`/api/documents/${id}/classify`, 'POST', 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 }),
return jsonRequest('/api/documents/upload-url', 'POST', {
filename,
content_type: contentType,
})
}
+4 -16
View File
@@ -4,7 +4,7 @@
* Consumers: stores/folders.js, FolderTreeItem.vue, AppSidebar.vue
*/
import { request } from './utils.js'
import { request, jsonRequest } from './utils.js'
export function listFolders(parentId = null) {
const params = new URLSearchParams()
@@ -14,11 +14,7 @@ export function listFolders(parentId = null) {
}
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 }),
})
return jsonRequest('/api/folders', 'POST', { name, parent_id: parentId || null })
}
export function getFolder(folderId) {
@@ -26,11 +22,7 @@ export function getFolder(folderId) {
}
export function renameFolder(folderId, name) {
return request(`/api/folders/${folderId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
return jsonRequest(`/api/folders/${folderId}`, 'PATCH', { name })
}
export function deleteFolder(folderId) {
@@ -38,9 +30,5 @@ export function deleteFolder(folderId) {
}
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 }),
})
return jsonRequest(`/api/documents/${docId}/folder`, 'PATCH', { folder_id: folderId || null })
}
+6 -10
View File
@@ -4,22 +4,18 @@
* Consumers: SharedView.vue, ShareModal.vue
*/
import { request } from './utils.js'
import { request, jsonRequest } from './utils.js'
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 }),
return jsonRequest('/api/shares', 'POST', {
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 }),
})
return jsonRequest(`/api/shares/${shareId}`, 'PATCH', { permission })
}
export function listShares(docId) {
+4 -16
View File
@@ -4,26 +4,18 @@
* Consumers: stores/topics.js
*/
import { request } from './utils.js'
import { request, jsonRequest } from './utils.js'
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 }),
})
return jsonRequest('/api/topics', 'POST', { name, description, color })
}
export function updateTopic(id, patch) {
return request(`/api/topics/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
})
return jsonRequest(`/api/topics/${id}`, 'PATCH', patch)
}
export function deleteTopic(id) {
@@ -31,9 +23,5 @@ export function deleteTopic(id) {
}
export function suggestTopics(documentId) {
return request('/api/topics/suggest', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ document_id: documentId }),
})
return jsonRequest('/api/topics/suggest', 'POST', { document_id: documentId })
}
+44 -82
View File
@@ -1,56 +1,42 @@
/**
* HTTP transport + 401-retry consolidator.
*
* `request()` moved from client.js to break the circular dependency:
* domain modules import request from here; client.js re-exports from those
* same domain modules, so request cannot also live in client.js.
*
* `fetchWithRetry()` consolidates 3 blob-download patterns that previously
* duplicated identical auth-injection + 401-retry boilerplate:
* - adminExportAuditLogCsv (admin.js)
* - adminDownloadDailyExport (admin.js)
* - fetchDocumentContent (documents.js)
*
* Security: Bearer token injected from authStore (Pinia memory only — CLAUDE.md).
* Token is NEVER read from localStorage or sessionStorage.
*/
const NO_REFRESH_PATHS = new Set(['/api/auth/login', '/api/auth/register', '/api/auth/refresh'])
/**
* Core HTTP transport. All JSON-returning endpoints go through this function.
*
* On 401: attempts one token refresh via authStore.refresh() then retries.
* Skip-refresh guard: login/register return 401 for bad credentials (not expired
* tokens), and refresh itself must not retry (would cause infinite loop).
*
* @param {string} path — API path (e.g. '/api/documents')
* @param {RequestInit & {_retry?: boolean}} [options] — fetch options
* @returns {Promise<any>} — parsed JSON response, or null for 204/empty body
*/
export async function request(path, options = {}) {
// Lazy import to avoid circular dependency (stores/auth.js → api/client.js → stores/auth.js)
async function getAuthStore() {
const { useAuthStore } = await import('../stores/auth.js')
const authStore = useAuthStore()
return useAuthStore()
}
function withAuthHeaders(options, accessToken) {
const headers = { ...(options.headers || {}) }
if (authStore.accessToken) {
headers['Authorization'] = `Bearer ${authStore.accessToken}`
if (accessToken) headers.Authorization = `Bearer ${accessToken}`
return { ...options, headers, credentials: 'include' }
}
function clearSession(authStore) {
authStore.accessToken = null
authStore.user = null
}
function fetchAuthenticated(url, options, authStore) {
return fetch(url, withAuthHeaders(options, authStore.accessToken))
}
async function refreshAndRetry(authStore, retryFn) {
try {
await authStore.refresh()
return retryFn()
} catch {
clearSession(authStore)
throw new Error('Session expired')
}
}
const res = await fetch(path, { ...options, headers, credentials: 'include' })
/** Core HTTP transport for JSON-returning API endpoints. */
export async function request(path, options = {}) {
const authStore = await getAuthStore()
const res = await fetch(path, withAuthHeaders(options, authStore.accessToken))
// 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.status === 401 && !options._retry && !NO_REFRESH_PATHS.has(path)) {
return refreshAndRetry(authStore, () => request(path, { ...options, _retry: true }))
}
if (!res.ok) {
@@ -74,45 +60,21 @@ export async function request(path, options = {}) {
return res.json()
}
/**
* Authenticated fetch with 401-retry for non-JSON responses (blobs, raw Response).
*
* Consolidates adminExportAuditLogCsv, adminDownloadDailyExport, fetchDocumentContent
* which share identical auth-injection + 401-retry boilerplate (CODE-08).
*
* Unlike request(), this does NOT parse the response — the raw Response is returned
* so callers can call .text(), .blob(), etc. as appropriate.
*
* Security: Bearer token from authStore.accessToken (memory only — CLAUDE.md).
* On 401-and-not-already-retried: calls authStore.refresh() and recurses with
* _retry=true. On refresh failure: clears in-memory token state and throws
* 'Session expired'.
*
* @param {string} url — full URL to fetch
* @param {RequestInit} [options] — fetch options (method, headers, etc.)
* @param {boolean} [_retry] — internal retry guard; callers must NOT pass this
* @returns {Promise<Response>} — raw Response; caller decides how to consume it
*/
export function jsonRequest(path, method, body) {
return request(path, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
}
/** Authenticated fetch with one refresh retry for raw/blob responses. */
export async function fetchWithRetry(url, options = {}, _retry = false) {
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(url, { ...options, headers, credentials: 'include' })
const authStore = await getAuthStore()
const res = await fetchAuthenticated(url, options, authStore)
if (res.status === 401 && !_retry) {
try {
await authStore.refresh()
return fetchWithRetry(url, options, true)
} catch {
authStore.accessToken = null
authStore.user = null
throw new Error('Session expired')
}
return refreshAndRetry(authStore, () => fetchWithRetry(url, options, true))
}
return res