94901fc30f
- Three-column layout: Sidebar + SourcePanel (views + searchable category tree) + main - DocumentSlideOver (480px right panel): inline editing, type picker, AI suggestion confirm/reject, categories combobox, tags editor, sharing section, raw text, re-analyse/delete actions - ManageCategoriesDialog: inline rename, delete with confirm, search filter - DocumentsPage rewrite: filter chip system, multi-file upload queue, drag-and-drop overlay, bulk actions bar (share/delete), smart TanStack Query polling, URL-driven view state - Sidebar simplified: per-category NavLinks removed; Documents = single NavLink under Apps - Backend: document_shares table (migration 0004), share CRUD endpoints, shared-with-me view, N+1-safe share_count via GROUP BY, recipient download access, X-User-Groups header enforcement - Gateway proxy: injects X-User-Groups header into all document + category proxy requests - Backend users: GET /api/users/me/groups endpoint for share picker combobox - CLAUDE.md, STATUS.md files, and changelog updated Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
470 lines
14 KiB
TypeScript
470 lines
14 KiB
TypeScript
import axios from "axios";
|
|
|
|
const api = axios.create({ baseURL: "/api" });
|
|
|
|
api.interceptors.request.use((config) => {
|
|
const token = localStorage.getItem("token");
|
|
if (token) config.headers.Authorization = `Bearer ${token}`;
|
|
return config;
|
|
});
|
|
|
|
export default api;
|
|
|
|
// --- Auth ---
|
|
export const login = (email: string, password: string) =>
|
|
api
|
|
.post<{ access_token: string }>("/auth/login", new URLSearchParams({ username: email, password }))
|
|
.then((r) => r.data.access_token);
|
|
|
|
export const register = (email: string, password: string, full_name?: string) =>
|
|
api.post("/auth/register", { email, password, full_name }).then((r) => r.data);
|
|
|
|
// --- Users ---
|
|
export interface UserData {
|
|
id: string;
|
|
email: string;
|
|
full_name: string | null;
|
|
is_active: boolean;
|
|
is_admin: boolean;
|
|
color_mode: string | null;
|
|
}
|
|
|
|
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
|
|
|
|
export interface DashboardPrefs {
|
|
app_ids: string[];
|
|
}
|
|
|
|
export const getDashboardPrefs = () =>
|
|
api.get<DashboardPrefs>("/users/me/preferences").then((r) => r.data);
|
|
|
|
export const updateDashboardPrefs = (app_ids: string[]) =>
|
|
api.patch<DashboardPrefs>("/users/me/preferences", { app_ids }).then((r) => r.data);
|
|
|
|
// --- Admin ---
|
|
export interface AdminUserCreate {
|
|
email: string;
|
|
password: string;
|
|
full_name?: string;
|
|
is_admin?: boolean;
|
|
}
|
|
|
|
export const adminGetUsers = () =>
|
|
api.get<UserData[]>("/admin/users").then((r) => r.data);
|
|
|
|
export const adminCreateUser = (data: AdminUserCreate) =>
|
|
api.post<UserData>("/admin/users", data).then((r) => r.data);
|
|
|
|
export const adminDeleteUser = (userId: string) =>
|
|
api.delete(`/admin/users/${userId}`);
|
|
|
|
export const adminToggleActive = (userId: string) =>
|
|
api.patch<UserData>(`/admin/users/${userId}/active`).then((r) => r.data);
|
|
|
|
// --- Profile ---
|
|
export interface ProfileData {
|
|
id: string;
|
|
user_id: string;
|
|
phone: string | null;
|
|
date_of_birth: string | null;
|
|
position: string | null;
|
|
address: string | null;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ProfileUpdate {
|
|
phone?: string | null;
|
|
date_of_birth?: string | null;
|
|
position?: string | null;
|
|
address?: string | null;
|
|
}
|
|
|
|
export const getProfile = () =>
|
|
api.get<ProfileData>("/profile/me").then((r) => r.data);
|
|
|
|
export const updateProfile = (data: ProfileUpdate) =>
|
|
api.put<ProfileData>("/profile/me", data).then((r) => r.data);
|
|
|
|
// --- Documents ---
|
|
export type DocumentStatus = "pending" | "processing" | "done" | "failed";
|
|
|
|
export interface CategoryOut {
|
|
id: string;
|
|
name: string;
|
|
}
|
|
|
|
export interface DocumentOut {
|
|
id: string;
|
|
user_id: string;
|
|
filename: string;
|
|
title: string | null;
|
|
file_size: number;
|
|
status: DocumentStatus;
|
|
document_type: string | null;
|
|
extracted_data: string | null;
|
|
tags: string | null;
|
|
error_message: string | null;
|
|
created_at: string;
|
|
processed_at: string | null;
|
|
categories: CategoryOut[];
|
|
source: string;
|
|
watch_path: string | null;
|
|
suggested_folder: string | null;
|
|
suggested_filename: string | null;
|
|
share_count: number;
|
|
}
|
|
|
|
export interface SharedDocumentOut extends DocumentOut {
|
|
shared_by_user_id: string;
|
|
shared_via_group_id: string;
|
|
}
|
|
|
|
export interface DocumentShareOut {
|
|
id: string;
|
|
document_id: string;
|
|
group_id: string;
|
|
shared_by_user_id: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface DocumentPage {
|
|
items: DocumentOut[];
|
|
total: number;
|
|
page: number;
|
|
pages: number;
|
|
}
|
|
|
|
export interface DocumentListParams {
|
|
page?: number;
|
|
per_page?: number;
|
|
sort?: string;
|
|
order?: "asc" | "desc";
|
|
status?: string;
|
|
document_type?: string;
|
|
search?: string;
|
|
category_id?: string;
|
|
}
|
|
|
|
export interface DocumentStatusOut {
|
|
id: string;
|
|
status: DocumentStatus;
|
|
document_type: string | null;
|
|
error_message: string | null;
|
|
processed_at: string | null;
|
|
}
|
|
|
|
export const listDocuments = (params: DocumentListParams = {}) =>
|
|
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
|
|
|
|
export const listSharedWithMe = (params: DocumentListParams = {}) =>
|
|
api.get<DocumentPage>("/documents/shared-with-me", { params }).then((r) => r.data);
|
|
|
|
export const getDocumentShares = (docId: string) =>
|
|
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`).then((r) => r.data);
|
|
|
|
export const addDocumentShare = (docId: string, groupId: string) =>
|
|
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }).then((r) => r.data);
|
|
|
|
export const removeDocumentShare = (docId: string, groupId: string) =>
|
|
api.delete(`/documents/${docId}/shares/${groupId}`);
|
|
|
|
export const getDocument = (id: string) =>
|
|
api.get<DocumentOut>(`/documents/${id}`).then((r) => r.data);
|
|
|
|
export const getDocumentStatus = (id: string) =>
|
|
api.get<DocumentStatusOut>(`/documents/${id}/status`).then((r) => r.data);
|
|
|
|
export const uploadDocument = (file: File) => {
|
|
const form = new FormData();
|
|
form.append("file", file);
|
|
return api.post<DocumentOut>("/documents/upload", form).then((r) => r.data);
|
|
};
|
|
|
|
export const updateDocumentType = (id: string, document_type: string) =>
|
|
api.patch<DocumentOut>(`/documents/${id}/type`, { document_type }).then((r) => r.data);
|
|
|
|
export const deleteDocument = (id: string) =>
|
|
api.delete(`/documents/${id}`);
|
|
|
|
export const downloadDocument = async (id: string, filename: string) => {
|
|
const response = await api.get(`/documents/${id}/file`, { responseType: "blob" });
|
|
const url = URL.createObjectURL(response.data);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
export const viewDocument = async (id: string): Promise<void> => {
|
|
const response = await api.get(`/documents/${id}/file`, { responseType: "blob" });
|
|
const url = URL.createObjectURL(response.data);
|
|
const win = window.open(url, "_blank");
|
|
// Revoke after a generous delay — the new tab needs time to load the blob
|
|
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
|
if (!win) alert("Pop-up blocked. Please allow pop-ups for this site to preview PDFs.");
|
|
};
|
|
|
|
export const updateDocumentTags = (id: string, tags: string[]) =>
|
|
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags }).then((r) => r.data);
|
|
|
|
export const updateDocumentTitle = (id: string, title: string) =>
|
|
api.patch<DocumentOut>(`/documents/${id}/title`, { title }).then((r) => r.data);
|
|
|
|
export const reprocessDocument = (id: string) =>
|
|
api.post<DocumentOut>(`/documents/${id}/reprocess`).then((r) => r.data);
|
|
|
|
export const assignCategory = (docId: string, catId: string) =>
|
|
api.post(`/documents/${docId}/categories/${catId}`);
|
|
|
|
export const removeCategory = (docId: string, catId: string) =>
|
|
api.delete(`/documents/${docId}/categories/${catId}`);
|
|
|
|
// --- Categories ---
|
|
export const listCategories = () =>
|
|
api.get<CategoryOut[]>("/documents/categories").then((r) => r.data);
|
|
|
|
export const createCategory = (name: string) =>
|
|
api.post<CategoryOut>("/documents/categories", { name }).then((r) => r.data);
|
|
|
|
export const renameCategory = (id: string, name: string) =>
|
|
api.patch<CategoryOut>(`/documents/categories/${id}`, { name }).then((r) => r.data);
|
|
|
|
export const deleteCategory = (id: string) =>
|
|
api.delete(`/documents/categories/${id}`);
|
|
|
|
// --- Appearance & Themes ---
|
|
export interface ThemeColors {
|
|
primary: string;
|
|
primary_hover: string;
|
|
accent: string;
|
|
accent_hover: string;
|
|
background: string;
|
|
surface: string;
|
|
border: string;
|
|
text_primary: string;
|
|
text_muted: string;
|
|
}
|
|
|
|
export interface ThemeDefinition {
|
|
id: string;
|
|
label: string;
|
|
builtin: boolean;
|
|
light: ThemeColors;
|
|
dark: ThemeColors;
|
|
}
|
|
|
|
export interface AppearanceSettings {
|
|
theme: string;
|
|
default_mode: string;
|
|
}
|
|
|
|
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
|
|
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
|
|
|
|
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
|
|
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
|
|
|
|
export const getThemes = (): Promise<ThemeDefinition[]> =>
|
|
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
|
|
|
|
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
|
|
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
|
|
|
|
export const updateTheme = (
|
|
id: string,
|
|
data: { label?: string; light?: ThemeColors; dark?: ThemeColors }
|
|
): Promise<ThemeDefinition> =>
|
|
api.patch<ThemeDefinition>(`/settings/themes/${id}`, data).then((r) => r.data);
|
|
|
|
export const deleteTheme = (id: string): Promise<void> =>
|
|
api.delete(`/settings/themes/${id}`).then((r) => r.data);
|
|
|
|
export const updateColorMode = (color_mode: string): Promise<UserData> =>
|
|
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
|
|
|
|
// --- Settings (admin only) ---
|
|
export interface AIProviderUpdate {
|
|
provider: string;
|
|
anthropic_api_key?: string;
|
|
anthropic_model?: string;
|
|
ollama_base_url?: string;
|
|
ollama_model?: string;
|
|
ollama_api_key?: string;
|
|
lmstudio_base_url?: string;
|
|
lmstudio_model?: string;
|
|
lmstudio_api_key?: string;
|
|
}
|
|
|
|
export const getAISettings = () =>
|
|
api.get<Record<string, unknown>>("/settings/ai").then((r) => r.data);
|
|
|
|
export const updateAISettings = (data: AIProviderUpdate) =>
|
|
api.patch<Record<string, unknown>>("/settings/ai", data).then((r) => r.data);
|
|
|
|
export const testAIConnection = () =>
|
|
api.post<{ ok: boolean; provider: string; response?: string; error?: string }>(
|
|
"/settings/ai/test"
|
|
).then((r) => r.data);
|
|
|
|
export const updateDocumentLimits = (max_pdf_mb: number) =>
|
|
api.patch<Record<string, unknown>>("/settings/documents/limits", { max_pdf_mb }).then(
|
|
(r) => r.data
|
|
);
|
|
|
|
export const getDocumentLimits = () =>
|
|
api.get<Record<string, unknown>>("/settings/documents/limits").then((r) => r.data);
|
|
|
|
// --- User groups (current user's own memberships) ---
|
|
export interface UserGroupOut {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
}
|
|
|
|
export const getMyGroups = () =>
|
|
api.get<UserGroupOut[]>("/users/me/groups").then((r) => r.data);
|
|
|
|
// --- Groups (admin only) ---
|
|
export interface GroupOut {
|
|
id: string;
|
|
name: string;
|
|
description: string | null;
|
|
created_at: string;
|
|
member_count: number;
|
|
}
|
|
|
|
export interface GroupMemberOut {
|
|
id: string;
|
|
email: string;
|
|
full_name: string | null;
|
|
is_active: boolean;
|
|
joined_at: string;
|
|
}
|
|
|
|
export interface GroupDetailOut extends GroupOut {
|
|
members: GroupMemberOut[];
|
|
}
|
|
|
|
export interface GroupCreate {
|
|
name: string;
|
|
description?: string | null;
|
|
}
|
|
|
|
export interface GroupUpdate {
|
|
name?: string;
|
|
description?: string | null;
|
|
}
|
|
|
|
export const adminListGroups = () =>
|
|
api.get<GroupOut[]>("/admin/groups").then((r) => r.data);
|
|
|
|
export const adminCreateGroup = (data: GroupCreate) =>
|
|
api.post<GroupOut>("/admin/groups", data).then((r) => r.data);
|
|
|
|
export const adminGetGroup = (groupId: string) =>
|
|
api.get<GroupDetailOut>(`/admin/groups/${groupId}`).then((r) => r.data);
|
|
|
|
export const adminUpdateGroup = (groupId: string, data: GroupUpdate) =>
|
|
api.patch<GroupOut>(`/admin/groups/${groupId}`, data).then((r) => r.data);
|
|
|
|
export const adminDeleteGroup = (groupId: string) =>
|
|
api.delete(`/admin/groups/${groupId}`);
|
|
|
|
export const adminAddGroupMember = (groupId: string, userId: string) =>
|
|
api.post(`/admin/groups/${groupId}/members/${userId}`);
|
|
|
|
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
|
|
api.delete(`/admin/groups/${groupId}/members/${userId}`);
|
|
|
|
// --- Services ---
|
|
export interface ServiceStatus {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
healthy: boolean;
|
|
app_path: string;
|
|
settings_path: string;
|
|
}
|
|
|
|
export const getServices = () =>
|
|
api.get<ServiceStatus[]>("/services").then((r) => r.data);
|
|
|
|
// --- System Prompts (admin only) ---
|
|
export interface ServiceSystemPrompt {
|
|
label: string;
|
|
system: string;
|
|
user_template: string;
|
|
default_system: string;
|
|
default_user_template: string;
|
|
}
|
|
|
|
export type SystemPromptsData = Record<string, ServiceSystemPrompt>;
|
|
|
|
export const getSystemPrompts = () =>
|
|
api.get<SystemPromptsData>("/settings/system-prompts").then((r) => r.data);
|
|
|
|
export const updateSystemPrompt = (
|
|
serviceId: string,
|
|
data: { system: string; user_template: string }
|
|
) =>
|
|
api
|
|
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
|
|
.then((r) => r.data);
|
|
|
|
// --- Document suggestions (watch-ingested documents) ---
|
|
export const confirmFolderSuggestion = (docId: string) =>
|
|
api.post(`/documents/${docId}/suggestions/folder/confirm`);
|
|
|
|
export const rejectFolderSuggestion = (docId: string) =>
|
|
api.post(`/documents/${docId}/suggestions/folder/reject`);
|
|
|
|
export const confirmFilenameSuggestion = (docId: string) =>
|
|
api.post(`/documents/${docId}/suggestions/filename/confirm`);
|
|
|
|
export const rejectFilenameSuggestion = (docId: string) =>
|
|
api.post(`/documents/${docId}/suggestions/filename/reject`);
|
|
|
|
// --- Plugins ---
|
|
export interface PluginOut {
|
|
id: string;
|
|
name: string;
|
|
icon: string;
|
|
version: string;
|
|
}
|
|
|
|
export interface PluginSchemaProperty {
|
|
type: string;
|
|
title: string;
|
|
description?: string;
|
|
readOnly?: boolean;
|
|
}
|
|
|
|
export interface PluginManifest {
|
|
id: string;
|
|
name: string;
|
|
icon: string;
|
|
version: string;
|
|
access: {
|
|
allow_superuser: boolean;
|
|
required_groups: string[];
|
|
};
|
|
settings_schema: {
|
|
type: string;
|
|
title?: string;
|
|
properties: Record<string, PluginSchemaProperty>;
|
|
};
|
|
}
|
|
|
|
export const getPlugins = () =>
|
|
api.get<PluginOut[]>("/plugins").then((r) => r.data);
|
|
|
|
export const getPluginManifest = (id: string) =>
|
|
api.get<PluginManifest>(`/plugins/${id}/manifest`).then((r) => r.data);
|
|
|
|
export const getPluginSettings = (id: string) =>
|
|
api.get<Record<string, unknown>>(`/plugins/${id}/settings`).then((r) => r.data);
|
|
|
|
export const updatePluginSettings = (id: string, data: Record<string, unknown>) =>
|
|
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data).then((r) => r.data);
|