Refactor backend and frontend cleanup paths
This commit is contained in:
+45
-75
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,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,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 })
|
||||
}
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
|
||||
<!-- ── Sticky toolbar ──────────────────────────────────────────────── -->
|
||||
<div class="sticky top-0 z-10 bg-white border-b border-gray-100">
|
||||
<div class="px-6 py-3 flex items-center gap-3 flex-wrap">
|
||||
<BreadcrumbBar
|
||||
@@ -29,16 +28,13 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Content area ───────────────────────────────────────────────── -->
|
||||
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||
|
||||
<!-- Upload zone -->
|
||||
<div class="px-6 pt-5 pb-3">
|
||||
<DropZone ref="dropZoneRef" @files-selected="$emit('upload', $event)" />
|
||||
<UploadProgress :items="uploadQueue" />
|
||||
</div>
|
||||
|
||||
<!-- Column headers — 5-column grid, consistent for local and cloud -->
|
||||
<div class="mx-6 px-4 py-2 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg bg-gray-50 text-xs font-semibold text-gray-400 uppercase tracking-wider select-none">
|
||||
<span></span>
|
||||
<span>Name</span>
|
||||
@@ -47,7 +43,6 @@
|
||||
<span></span>
|
||||
</div>
|
||||
|
||||
<!-- Inline new-folder row (local only) -->
|
||||
<div v-if="showNewFolderInput" class="mx-6 mt-1 px-4 py-2.5 grid grid-cols-[2rem_1fr_6rem_8rem_6rem] gap-3 items-center rounded-lg border border-amber-200 bg-amber-50/40">
|
||||
<div class="w-7 h-7 bg-amber-50 rounded-lg flex items-center justify-center">
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-400" />
|
||||
@@ -68,10 +63,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Item list ─────────────────────────────────────────────────── -->
|
||||
<div class="mx-6 mt-1 mb-6 flex flex-col divide-y divide-gray-100 border border-gray-100 rounded-xl overflow-hidden">
|
||||
|
||||
<!-- Folder rows -->
|
||||
<div
|
||||
v-for="folder in folders"
|
||||
:key="`f-${folder.id}`"
|
||||
@@ -89,7 +82,6 @@
|
||||
<AppIcon name="folder" class="w-4 h-4 text-amber-500" />
|
||||
</div>
|
||||
|
||||
<!-- Name / rename input -->
|
||||
<div>
|
||||
<input
|
||||
v-if="renamingId === folder.id"
|
||||
@@ -105,7 +97,6 @@
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">—</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(folder.created_at) }}</span>
|
||||
|
||||
<!-- Folder actions (local only) -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<button @click.stop="startRename(folder)" title="Rename"
|
||||
@@ -135,7 +126,6 @@
|
||||
<AppIcon name="document" class="w-4 h-4 text-indigo-400" />
|
||||
</div>
|
||||
|
||||
<!-- Name + topics + shared badge -->
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm font-medium text-gray-900 truncate">{{ file.original_name ?? file.name }}</p>
|
||||
@@ -154,15 +144,12 @@
|
||||
<span class="text-right text-xs text-gray-400 hidden md:block">{{ formatSize(file.size_bytes ?? file.size) }}</span>
|
||||
<span class="text-right text-xs text-gray-400 hidden sm:block">{{ formatDate(file.created_at) }}</span>
|
||||
|
||||
<!-- File actions (local only) -->
|
||||
<div class="flex justify-end gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" @click.stop>
|
||||
<template v-if="mode === 'local'">
|
||||
<!-- Share -->
|
||||
<button @click.stop="$emit('file-share', file)" title="Share"
|
||||
class="p-1.5 rounded hover:bg-gray-200 text-gray-400 hover:text-gray-700 transition-colors">
|
||||
<AppIcon name="share" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<!-- Move -->
|
||||
<div class="relative">
|
||||
<button
|
||||
@click.stop="openFolderPicker(file.id, $event)"
|
||||
@@ -172,7 +159,6 @@
|
||||
<AppIcon name="folderMove" class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<!-- Delete -->
|
||||
<button @click.stop="$emit('file-delete', file.id)" title="Delete"
|
||||
class="p-1.5 rounded hover:bg-red-50 text-gray-400 hover:text-red-500 transition-colors">
|
||||
<AppIcon name="trash" class="w-3.5 h-3.5" />
|
||||
@@ -225,7 +211,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Teleported folder picker — avoids overflow:hidden clipping -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="folderPickerFileId"
|
||||
@@ -305,13 +290,9 @@ function topicColor(name) {
|
||||
return props.topicColorFn(name)
|
||||
}
|
||||
|
||||
// ── Child component refs ──────────────────────────────────────────────────────
|
||||
|
||||
const dropZoneRef = ref(null)
|
||||
const searchBarRef = ref(null)
|
||||
|
||||
// ── New folder inline input ───────────────────────────────────────────────────
|
||||
|
||||
const showNewFolderInput = ref(false)
|
||||
const newFolderName = ref('')
|
||||
const newFolderError = ref('')
|
||||
@@ -331,8 +312,17 @@ function cancelNewFolder() {
|
||||
|
||||
async function submitNewFolder() {
|
||||
const name = newFolderName.value.trim()
|
||||
if (!name) { newFolderError.value = 'Folder name cannot be empty.'; return }
|
||||
emit('folder-create', { name, onError: (msg) => { newFolderError.value = msg }, onSuccess: cancelNewFolder })
|
||||
if (!name) {
|
||||
newFolderError.value = 'Folder name cannot be empty.'
|
||||
return
|
||||
}
|
||||
emit('folder-create', {
|
||||
name,
|
||||
onError: (msg) => {
|
||||
newFolderError.value = msg
|
||||
},
|
||||
onSuccess: cancelNewFolder,
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -342,8 +332,6 @@ defineExpose({
|
||||
clearSearch: () => emit('search-change', ''),
|
||||
})
|
||||
|
||||
// ── Rename ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const renamingId = ref(null)
|
||||
const renameValue = ref('')
|
||||
|
||||
@@ -352,8 +340,6 @@ function startRename(folder) {
|
||||
renameValue.value = folder.name
|
||||
}
|
||||
|
||||
// ── Drag and drop (local only) ────────────────────────────────────────────────
|
||||
|
||||
const draggingFile = ref(null)
|
||||
const dragOverFolderId = ref(null)
|
||||
|
||||
@@ -383,11 +369,8 @@ async function onDropDocOnFolder(folderId) {
|
||||
draggingFile.value = null
|
||||
}
|
||||
|
||||
// ── Move folder picker (Teleport + getBoundingClientRect) ─────────────────────
|
||||
|
||||
const folderPickerFileId = ref(null)
|
||||
const pickerStyle = ref({})
|
||||
// Map fileId → trigger button element, so scroll listener can reposition
|
||||
const pickerTriggerMap = new Map()
|
||||
|
||||
function updatePickerPosition(rect) {
|
||||
@@ -409,7 +392,6 @@ function updatePickerPosition(rect) {
|
||||
}
|
||||
|
||||
function openFolderPicker(fileId, ev) {
|
||||
// Toggle off if same file
|
||||
if (folderPickerFileId.value === fileId) {
|
||||
folderPickerFileId.value = null
|
||||
return
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
<input
|
||||
v-model="formByProvider[prov.provider_id].base_url"
|
||||
type="text"
|
||||
:placeholder="providerDefaultBaseUrl(prov.provider_id) || '(default)'"
|
||||
:placeholder="providerDefaultBaseUrl(prov) || '(default)'"
|
||||
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
@@ -71,7 +71,7 @@
|
||||
<SearchableModelSelect
|
||||
v-model="formByProvider[prov.provider_id].model_name"
|
||||
:provider-id="prov.provider_id"
|
||||
:placeholder="providerDefaultModel(prov.provider_id)"
|
||||
:placeholder="providerDefaultModel(prov)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
<input
|
||||
v-model.number="formByProvider[prov.provider_id].context_chars"
|
||||
type="number"
|
||||
:placeholder="String(providerDefaultContextChars(prov.provider_id))"
|
||||
:placeholder="String(providerDefaultContextChars(prov))"
|
||||
min="1000"
|
||||
max="2000000"
|
||||
class="block w-full rounded-lg px-3 py-2 text-sm border border-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-colors"
|
||||
@@ -131,8 +131,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Per-user AI provider assignment (existing — DO NOT MODIFY) ─────── -->
|
||||
|
||||
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
|
||||
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
|
||||
@@ -210,25 +208,6 @@ import AppIcon from '../../components/ui/AppIcon.vue'
|
||||
import SearchableModelSelect from '../../components/ui/SearchableModelSelect.vue'
|
||||
import BreadcrumbBar from '../../components/ui/BreadcrumbBar.vue'
|
||||
|
||||
|
||||
const _PROVIDER_DEFAULTS = {
|
||||
openai: { base_url: null, model: 'gpt-4o', context_chars: 120000 },
|
||||
anthropic: { base_url: null, model: 'claude-sonnet-4-6', context_chars: 180000 },
|
||||
gemini: { base_url: 'https://generativelanguage.googleapis.com/v1beta/openai/', model: 'gemini-2.0-flash', context_chars: 800000 },
|
||||
groq: { base_url: 'https://api.groq.com/openai/v1', model: 'llama-3.3-70b-versatile', context_chars: 128000 },
|
||||
xai: { base_url: 'https://api.x.ai/v1', model: 'grok-3-mini', context_chars: 128000 },
|
||||
deepseek: { base_url: 'https://api.deepseek.com', model: 'deepseek-chat', context_chars: 60000 },
|
||||
openrouter: { base_url: 'https://openrouter.ai/api/v1', model: 'anthropic/claude-3.5-sonnet', context_chars: 180000 },
|
||||
mistral: { base_url: 'https://api.mistral.ai/v1', model: 'mistral-large-latest', context_chars: 128000 },
|
||||
ollama: { base_url: 'http://host.docker.internal:11434/v1', model: 'llama3.2', context_chars: 8000 },
|
||||
lmstudio: { base_url: 'http://host.docker.internal:1234/v1', model: 'gemma-4-e4b-it', context_chars: 8000 },
|
||||
}
|
||||
|
||||
function providerDefaultBaseUrl(pid) { return _PROVIDER_DEFAULTS[pid]?.base_url || '' }
|
||||
function providerDefaultModel(pid) { return _PROVIDER_DEFAULTS[pid]?.model || '' }
|
||||
function providerDefaultContextChars(pid) { return _PROVIDER_DEFAULTS[pid]?.context_chars || 8000 }
|
||||
|
||||
|
||||
const systemProviders = ref([])
|
||||
const loadingSystem = ref(false)
|
||||
const systemError = ref(null)
|
||||
@@ -238,6 +217,18 @@ const testResults = reactive({})
|
||||
const savingProvider = ref(null)
|
||||
const testingProvider = ref(null)
|
||||
|
||||
function providerDefaultBaseUrl(provider) {
|
||||
return provider?.base_url || ''
|
||||
}
|
||||
|
||||
function providerDefaultModel(provider) {
|
||||
return provider?.model_name || ''
|
||||
}
|
||||
|
||||
function providerDefaultContextChars(provider) {
|
||||
return provider?.context_chars || 8000
|
||||
}
|
||||
|
||||
function toggleProvider(pid) {
|
||||
openProviderId.value = openProviderId.value === pid ? null : pid
|
||||
}
|
||||
|
||||
@@ -291,7 +291,7 @@ function generateRandomPassword() {
|
||||
const lower = 'abcdefghijkmnpqrstuvwxyz'
|
||||
const digits = '23456789'
|
||||
const special = '!@#$%^&*'
|
||||
const charset = upper + lower + digits + special // 64 chars — 256 % 64 === 0, no modulo bias
|
||||
const charset = upper + lower + digits + special
|
||||
|
||||
const arr = new Uint8Array(16)
|
||||
crypto.getRandomValues(arr)
|
||||
@@ -396,62 +396,60 @@ function cancelDelete() {
|
||||
}
|
||||
|
||||
async function confirmDoDelete(id) {
|
||||
pendingAction[id] = true
|
||||
deleteError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminDeleteUser(id, deletePassword.value)
|
||||
users.value = users.value.filter(u => u.id !== id)
|
||||
cancelDelete()
|
||||
} catch (e) {
|
||||
}, (e) => {
|
||||
deleteError.value = e.message
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDoDeactivate(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminDeactivateUser(id)
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], is_active: false }
|
||||
}
|
||||
updateUser(id, { is_active: false })
|
||||
confirmDeactivate.value = null
|
||||
} catch (e) {
|
||||
}, (e) => {
|
||||
actionError.value = e.message
|
||||
confirmDeactivate.value = null
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function reactivate(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await runUserAction(id, async () => {
|
||||
await api.adminReactivateUser(id)
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], is_active: true }
|
||||
}
|
||||
} catch (e) {
|
||||
updateUser(id, { is_active: true })
|
||||
}, (e) => {
|
||||
actionError.value = e.message
|
||||
})
|
||||
}
|
||||
|
||||
async function resetPassword(id) {
|
||||
actionError.value = null
|
||||
await runUserAction(id, () => api.adminResetUserPassword(id), (e) => {
|
||||
actionError.value = e.message
|
||||
})
|
||||
}
|
||||
|
||||
async function runUserAction(id, action, onError) {
|
||||
pendingAction[id] = true
|
||||
try {
|
||||
await action()
|
||||
} catch (e) {
|
||||
onError(e)
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(id) {
|
||||
pendingAction[id] = true
|
||||
actionError.value = null
|
||||
try {
|
||||
await api.adminResetUserPassword(id)
|
||||
} catch (e) {
|
||||
actionError.value = e.message
|
||||
} finally {
|
||||
delete pendingAction[id]
|
||||
function updateUser(id, patch) {
|
||||
const idx = users.value.findIndex(u => u.id === id)
|
||||
if (idx !== -1) {
|
||||
users.value[idx] = { ...users.value[idx], ...patch }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user