Replace Axios with native fetch; add global 401 session-expiry redirect
All API calls now go through a thin request() wrapper around native fetch. Removes the axios dependency entirely. The wrapper injects the JWT on every request and — the key fix — clears localStorage and redirects to /login on any 401 response, so expired sessions no longer leave users on broken pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,12 @@ This file provides permanent, authoritative guidance to Claude Code for every se
|
||||
|
||||
---
|
||||
|
||||
## Merge checklist
|
||||
|
||||
Before merging any feature branch into `main`, every test relevant to the changed area in `tests/MERGE_CHECKLIST.md` must be marked passing. The checklist covers all 19 feature areas (auth, users, admin, groups, appearance, service health, plugins, AI settings, doc settings, upload/processing, list/filtering, slide-over, sharing, categories, bulk actions, watch directory, AI queue, infrastructure/security, and frontend routing). Do not merge without it.
|
||||
|
||||
---
|
||||
|
||||
## CLAUDE.md self-update checkpoint
|
||||
|
||||
**After every change to the codebase**, before committing, check which CLAUDE.md files need updating:
|
||||
@@ -71,6 +77,7 @@ For service-specific commands (migrations, lint), see `backend/CLAUDE.md` and `f
|
||||
├── .githooks/pre-commit ← Runs scripts/security_check.py before every commit
|
||||
├── scripts/security_check.py ← Static analysis: secrets, weak crypto, SQLi, JWT
|
||||
├── changelog/YYYY-MM-DD_<slug>.md ← Per-date change logs
|
||||
├── tests/MERGE_CHECKLIST.md ← 148-test pre-merge checklist (all features); must pass before merging to main
|
||||
├── dev-watch/ ← Dev bind-mount for file watcher testing (.gitkeep only)
|
||||
│
|
||||
├── backend/ ← FastAPI gateway (port 8000, internal); see backend/CLAUDE.md
|
||||
|
||||
+21
-9
@@ -22,7 +22,7 @@ frontend/
|
||||
├── src/
|
||||
│ ├── main.tsx ← React root, QueryClientProvider, BrowserRouter
|
||||
│ ├── App.tsx ← Route tree, PrivateRoute, AdminRoute
|
||||
│ ├── api/client.ts ← Axios instance + ALL API functions (single source of truth)
|
||||
│ ├── api/client.ts ← Native fetch wrapper (`request()`) + ALL API functions (single source of truth); no Axios
|
||||
│ ├── hooks/
|
||||
│ │ ├── useAuth.ts ← Token state (localStorage), login/logout
|
||||
│ │ └── useTheme.ts ← Theme toggle
|
||||
@@ -86,21 +86,33 @@ frontend/
|
||||
|
||||
### API client (`src/api/client.ts`)
|
||||
|
||||
Single Axios instance — **all** API calls live here, nowhere else:
|
||||
**No Axios** — uses a thin native `fetch` wrapper. All API calls live here, nowhere else.
|
||||
|
||||
The core `request()` function handles:
|
||||
- Prepends `/api` base URL
|
||||
- Injects `Authorization: Bearer {token}` from `localStorage` on every request
|
||||
- **Global 401 handler**: clears `localStorage` token and redirects to `/login` via `window.location.href` — this is the expired-session redirect
|
||||
- Throws `ApiError(status, detail)` on non-2xx responses (detail parsed from JSON body)
|
||||
- Returns `undefined` on 204 No Content
|
||||
- Supports `blob: true` for file download/preview responses
|
||||
|
||||
```typescript
|
||||
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;
|
||||
});
|
||||
// The internal api object — use these methods in exported functions:
|
||||
api.get<T>(path, params?) // GET with optional query params object
|
||||
api.post<T>(path, json?) // POST with JSON body
|
||||
api.postForm<T>(path, URLSearchParams) // POST with form-encoded body (login)
|
||||
api.postFile<T>(path, FormData) // POST with multipart body (file upload)
|
||||
api.patch<T>(path, json?) // PATCH with JSON body
|
||||
api.delete<T>(path) // DELETE
|
||||
api.getBlob(path) // GET → Blob (download / view)
|
||||
```
|
||||
|
||||
Adding a new API call:
|
||||
1. Define a TypeScript interface for the response if it's new.
|
||||
2. Add a named export function (`getX`, `createX`, `updateX`, `deleteX`).
|
||||
3. Use `api.get<T>(...)`, `api.post<T>(...)`, etc.; always `.then((r) => r.data)`.
|
||||
3. Use the appropriate `api.*` method — return the promise directly (no `.then((r) => r.data)`).
|
||||
|
||||
Error handling in components: catch blocks receive an `ApiError` instance with `.status` and `.message` (the detail string).
|
||||
|
||||
### TanStack Query conventions
|
||||
|
||||
|
||||
@@ -130,6 +130,8 @@ Display and edit personal information.
|
||||
|
||||
## API client (`src/api/client.ts`)
|
||||
|
||||
**No Axios** — replaced with a native `fetch` wrapper (`request()`). Global 401 handler clears the token and redirects to `/login`, fixing the expired-session blank-page bug. All exported function signatures are unchanged.
|
||||
|
||||
Key document-related functions:
|
||||
|
||||
| Function | Description |
|
||||
@@ -199,6 +201,7 @@ Key document-related functions:
|
||||
- [x] Document sharing UI (Sharing section + Shared with me view)
|
||||
- [x] AI suggestion confirm/reject UI (folder + filename)
|
||||
- [x] Groups admin UI
|
||||
- [x] Replace Axios with native fetch; add global 401 → `/login` redirect for expired sessions
|
||||
- [ ] Toast notification system
|
||||
- [ ] Loading skeletons
|
||||
- [ ] Cmd+K global search (`CommandDialog`)
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "^1.0.2",
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"lucide-react": "^0.400.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
+219
-101
@@ -1,25 +1,126 @@
|
||||
import axios from "axios";
|
||||
const BASE = "/api";
|
||||
|
||||
const api = axios.create({ baseURL: "/api" });
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core fetch wrapper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
detail: string;
|
||||
|
||||
constructor(status: number, detail: string) {
|
||||
super(detail);
|
||||
this.status = status;
|
||||
this.detail = detail;
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
options: {
|
||||
json?: unknown;
|
||||
form?: URLSearchParams;
|
||||
body?: FormData;
|
||||
params?: Record<string, string | number | undefined>;
|
||||
blob?: boolean;
|
||||
} = {}
|
||||
): Promise<T> {
|
||||
const { json, form, body, params, blob: asBlob } = options;
|
||||
|
||||
// Build URL with optional query params
|
||||
let url = `${BASE}${path}`;
|
||||
if (params) {
|
||||
const qs = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== "") qs.set(k, String(v));
|
||||
}
|
||||
const str = qs.toString();
|
||||
if (str) url += `?${str}`;
|
||||
}
|
||||
|
||||
// Build headers
|
||||
const headers: Record<string, string> = {};
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
if (token) headers["Authorization"] = `Bearer ${token}`;
|
||||
if (json !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (form !== undefined) headers["Content-Type"] = "application/x-www-form-urlencoded";
|
||||
// FormData: intentionally omit Content-Type so browser sets multipart boundary
|
||||
|
||||
// Build body
|
||||
let requestBody: BodyInit | undefined;
|
||||
if (json !== undefined) requestBody = JSON.stringify(json);
|
||||
else if (form !== undefined) requestBody = form;
|
||||
else if (body !== undefined) requestBody = body;
|
||||
|
||||
const response = await fetch(url, { method, headers, body: requestBody });
|
||||
|
||||
// Global 401 handler — expired or invalid token
|
||||
if (response.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
// Throw so callers don't try to process the response
|
||||
throw new ApiError(401, "Session expired");
|
||||
}
|
||||
|
||||
// Parse error responses
|
||||
if (!response.ok) {
|
||||
let detail = `HTTP ${response.status}`;
|
||||
try {
|
||||
const err = await response.json();
|
||||
if (typeof err.detail === "string") detail = err.detail;
|
||||
else if (Array.isArray(err.detail)) detail = err.detail.map((e: { msg: string }) => e.msg).join(", ");
|
||||
} catch {
|
||||
// non-JSON error body — keep default message
|
||||
}
|
||||
throw new ApiError(response.status, detail);
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (response.status === 204) return undefined as T;
|
||||
|
||||
if (asBlob) return response.blob() as Promise<T>;
|
||||
|
||||
const text = await response.text();
|
||||
return text ? (JSON.parse(text) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
const api = {
|
||||
get: <T>(path: string, params?: Record<string, string | number | undefined>) =>
|
||||
request<T>("GET", path, { params }),
|
||||
post: <T>(path: string, json?: unknown) =>
|
||||
request<T>("POST", path, { json }),
|
||||
postForm: <T>(path: string, form: URLSearchParams) =>
|
||||
request<T>("POST", path, { form }),
|
||||
postFile: <T>(path: string, body: FormData) =>
|
||||
request<T>("POST", path, { body }),
|
||||
patch: <T>(path: string, json?: unknown) =>
|
||||
request<T>("PATCH", path, { json }),
|
||||
delete: <T>(path: string) =>
|
||||
request<T>("DELETE", path),
|
||||
getBlob: (path: string) =>
|
||||
request<Blob>("GET", path, { blob: true }),
|
||||
};
|
||||
|
||||
export default api;
|
||||
|
||||
// --- Auth ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
api.postForm<{ access_token: string }>(
|
||||
"/auth/login",
|
||||
new URLSearchParams({ username: email, password })
|
||||
).then((r) => r.access_token);
|
||||
|
||||
export const register = (email: string, password: string, full_name?: string) =>
|
||||
api.post("/auth/register", { email, password, full_name }).then((r) => r.data);
|
||||
api.post("/auth/register", { email, password, full_name });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Users ---
|
||||
export interface UserData {
|
||||
id: string;
|
||||
email: string;
|
||||
@@ -29,19 +130,21 @@ export interface UserData {
|
||||
color_mode: string | null;
|
||||
}
|
||||
|
||||
export const getMe = () => api.get<UserData>("/users/me").then((r) => r.data);
|
||||
export const getMe = () => api.get<UserData>("/users/me");
|
||||
|
||||
export interface DashboardPrefs {
|
||||
app_ids: string[];
|
||||
}
|
||||
|
||||
export const getDashboardPrefs = () =>
|
||||
api.get<DashboardPrefs>("/users/me/preferences").then((r) => r.data);
|
||||
export const getDashboardPrefs = () => api.get<DashboardPrefs>("/users/me/preferences");
|
||||
|
||||
export const updateDashboardPrefs = (app_ids: string[]) =>
|
||||
api.patch<DashboardPrefs>("/users/me/preferences", { app_ids }).then((r) => r.data);
|
||||
api.patch<DashboardPrefs>("/users/me/preferences", { app_ids });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin — Users
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Admin ---
|
||||
export interface AdminUserCreate {
|
||||
email: string;
|
||||
password: string;
|
||||
@@ -49,19 +152,21 @@ export interface AdminUserCreate {
|
||||
is_admin?: boolean;
|
||||
}
|
||||
|
||||
export const adminGetUsers = () =>
|
||||
api.get<UserData[]>("/admin/users").then((r) => r.data);
|
||||
export const adminGetUsers = () => api.get<UserData[]>("/admin/users");
|
||||
|
||||
export const adminCreateUser = (data: AdminUserCreate) =>
|
||||
api.post<UserData>("/admin/users", data).then((r) => r.data);
|
||||
api.post<UserData>("/admin/users", 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);
|
||||
api.patch<UserData>(`/admin/users/${userId}/active`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Profile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Profile ---
|
||||
export interface ProfileData {
|
||||
id: string;
|
||||
user_id: string;
|
||||
@@ -79,13 +184,15 @@ export interface ProfileUpdate {
|
||||
address?: string | null;
|
||||
}
|
||||
|
||||
export const getProfile = () =>
|
||||
api.get<ProfileData>("/profile/me").then((r) => r.data);
|
||||
export const getProfile = () => api.get<ProfileData>("/profile/me");
|
||||
|
||||
export const updateProfile = (data: ProfileUpdate) =>
|
||||
api.put<ProfileData>("/profile/me", data).then((r) => r.data);
|
||||
api.patch<ProfileData>("/profile/me", data);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Documents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Documents ---
|
||||
export type DocumentStatus = "pending" | "processing" | "done" | "failed";
|
||||
|
||||
export interface CategoryOut {
|
||||
@@ -154,41 +261,40 @@ export interface DocumentStatusOut {
|
||||
}
|
||||
|
||||
export const listDocuments = (params: DocumentListParams = {}) =>
|
||||
api.get<DocumentPage>("/documents", { params }).then((r) => r.data);
|
||||
api.get<DocumentPage>("/documents", params as Record<string, string | number | undefined>);
|
||||
|
||||
export const listSharedWithMe = (params: DocumentListParams = {}) =>
|
||||
api.get<DocumentPage>("/documents/shared-with-me", { params }).then((r) => r.data);
|
||||
api.get<DocumentPage>("/documents/shared-with-me", params as Record<string, string | number | undefined>);
|
||||
|
||||
export const getDocumentShares = (docId: string) =>
|
||||
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`).then((r) => r.data);
|
||||
api.get<DocumentShareOut[]>(`/documents/${docId}/shares`);
|
||||
|
||||
export const addDocumentShare = (docId: string, groupId: string) =>
|
||||
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId }).then((r) => r.data);
|
||||
api.post<DocumentShareOut>(`/documents/${docId}/shares`, { group_id: groupId });
|
||||
|
||||
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);
|
||||
api.get<DocumentOut>(`/documents/${id}`);
|
||||
|
||||
export const getDocumentStatus = (id: string) =>
|
||||
api.get<DocumentStatusOut>(`/documents/${id}/status`).then((r) => r.data);
|
||||
api.get<DocumentStatusOut>(`/documents/${id}/status`);
|
||||
|
||||
export const uploadDocument = (file: File) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
return api.post<DocumentOut>("/documents/upload", form).then((r) => r.data);
|
||||
return api.postFile<DocumentOut>("/documents/upload", form);
|
||||
};
|
||||
|
||||
export const updateDocumentType = (id: string, document_type: string) =>
|
||||
api.patch<DocumentOut>(`/documents/${id}/type`, { document_type }).then((r) => r.data);
|
||||
api.patch<DocumentOut>(`/documents/${id}/type`, { document_type });
|
||||
|
||||
export const deleteDocument = (id: string) =>
|
||||
api.delete(`/documents/${id}`);
|
||||
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 blob = await api.getBlob(`/documents/${id}/file`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
@@ -197,22 +303,21 @@ export const downloadDocument = async (id: string, filename: string) => {
|
||||
};
|
||||
|
||||
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 blob = await api.getBlob(`/documents/${id}/file`);
|
||||
const url = URL.createObjectURL(blob);
|
||||
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);
|
||||
api.patch<DocumentOut>(`/documents/${id}/tags`, { tags });
|
||||
|
||||
export const updateDocumentTitle = (id: string, title: string) =>
|
||||
api.patch<DocumentOut>(`/documents/${id}/title`, { title }).then((r) => r.data);
|
||||
api.patch<DocumentOut>(`/documents/${id}/title`, { title });
|
||||
|
||||
export const reprocessDocument = (id: string) =>
|
||||
api.post<DocumentOut>(`/documents/${id}/reprocess`).then((r) => r.data);
|
||||
api.post<DocumentOut>(`/documents/${id}/reprocess`);
|
||||
|
||||
export const assignCategory = (docId: string, catId: string) =>
|
||||
api.post(`/documents/${docId}/categories/${catId}`);
|
||||
@@ -220,20 +325,24 @@ export const assignCategory = (docId: string, catId: string) =>
|
||||
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);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Categories
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const listCategories = () => api.get<CategoryOut[]>("/documents/categories");
|
||||
|
||||
export const createCategory = (name: string) =>
|
||||
api.post<CategoryOut>("/documents/categories", { name }).then((r) => r.data);
|
||||
api.post<CategoryOut>("/documents/categories", { name });
|
||||
|
||||
export const renameCategory = (id: string, name: string) =>
|
||||
api.patch<CategoryOut>(`/documents/categories/${id}`, { name }).then((r) => r.data);
|
||||
api.patch<CategoryOut>(`/documents/categories/${id}`, { name });
|
||||
|
||||
export const deleteCategory = (id: string) =>
|
||||
api.delete(`/documents/categories/${id}`);
|
||||
export const deleteCategory = (id: string) => api.delete(`/documents/categories/${id}`);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Appearance & Themes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Appearance & Themes ---
|
||||
export interface ThemeColors {
|
||||
primary: string;
|
||||
primary_hover: string;
|
||||
@@ -259,31 +368,31 @@ export interface AppearanceSettings {
|
||||
default_mode: string;
|
||||
}
|
||||
|
||||
export const getAppearanceSettings = (): Promise<AppearanceSettings> =>
|
||||
api.get<AppearanceSettings>("/settings/appearance").then((r) => r.data);
|
||||
export const getAppearanceSettings = () =>
|
||||
api.get<AppearanceSettings>("/settings/appearance");
|
||||
|
||||
export const updateAppearanceSettings = (data: AppearanceSettings): Promise<AppearanceSettings> =>
|
||||
api.patch<AppearanceSettings>("/settings/appearance", data).then((r) => r.data);
|
||||
export const updateAppearanceSettings = (data: AppearanceSettings) =>
|
||||
api.patch<AppearanceSettings>("/settings/appearance", data);
|
||||
|
||||
export const getThemes = (): Promise<ThemeDefinition[]> =>
|
||||
api.get<ThemeDefinition[]>("/settings/themes").then((r) => r.data);
|
||||
export const getThemes = () => api.get<ThemeDefinition[]>("/settings/themes");
|
||||
|
||||
export const createTheme = (data: Omit<ThemeDefinition, "builtin">): Promise<ThemeDefinition> =>
|
||||
api.post<ThemeDefinition>("/settings/themes", data).then((r) => r.data);
|
||||
export const createTheme = (data: Omit<ThemeDefinition, "builtin">) =>
|
||||
api.post<ThemeDefinition>("/settings/themes", 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);
|
||||
) => api.patch<ThemeDefinition>(`/settings/themes/${id}`, data);
|
||||
|
||||
export const deleteTheme = (id: string): Promise<void> =>
|
||||
api.delete(`/settings/themes/${id}`).then((r) => r.data);
|
||||
export const deleteTheme = (id: string) => api.delete(`/settings/themes/${id}`);
|
||||
|
||||
export const updateColorMode = (color_mode: string): Promise<UserData> =>
|
||||
api.patch<UserData>("/users/me/color-mode", { color_mode }).then((r) => r.data);
|
||||
export const updateColorMode = (color_mode: string) =>
|
||||
api.patch<UserData>("/users/me/color-mode", { color_mode });
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Settings (admin only) ---
|
||||
export interface AIProviderUpdate {
|
||||
provider: string;
|
||||
anthropic_api_key?: string;
|
||||
@@ -297,35 +406,38 @@ export interface AIProviderUpdate {
|
||||
}
|
||||
|
||||
export const getAISettings = () =>
|
||||
api.get<Record<string, unknown>>("/settings/ai").then((r) => r.data);
|
||||
api.get<Record<string, unknown>>("/settings/ai");
|
||||
|
||||
export const updateAISettings = (data: AIProviderUpdate) =>
|
||||
api.patch<Record<string, unknown>>("/settings/ai", data).then((r) => r.data);
|
||||
api.patch<Record<string, unknown>>("/settings/ai", 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);
|
||||
export const updateDocumentLimits = (max_pdf_mb: number) =>
|
||||
api.patch<Record<string, unknown>>("/settings/documents/limits", { max_pdf_mb });
|
||||
|
||||
export const getDocumentLimits = () =>
|
||||
api.get<Record<string, unknown>>("/settings/documents/limits");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// User groups (current user's own memberships)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- 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);
|
||||
export const getMyGroups = () => api.get<UserGroupOut[]>("/users/me/groups");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Groups (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Groups (admin only) ---
|
||||
export interface GroupOut {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -356,17 +468,16 @@ export interface GroupUpdate {
|
||||
description?: string | null;
|
||||
}
|
||||
|
||||
export const adminListGroups = () =>
|
||||
api.get<GroupOut[]>("/admin/groups").then((r) => r.data);
|
||||
export const adminListGroups = () => api.get<GroupOut[]>("/admin/groups");
|
||||
|
||||
export const adminCreateGroup = (data: GroupCreate) =>
|
||||
api.post<GroupOut>("/admin/groups", data).then((r) => r.data);
|
||||
api.post<GroupOut>("/admin/groups", data);
|
||||
|
||||
export const adminGetGroup = (groupId: string) =>
|
||||
api.get<GroupDetailOut>(`/admin/groups/${groupId}`).then((r) => r.data);
|
||||
api.get<GroupDetailOut>(`/admin/groups/${groupId}`);
|
||||
|
||||
export const adminUpdateGroup = (groupId: string, data: GroupUpdate) =>
|
||||
api.patch<GroupOut>(`/admin/groups/${groupId}`, data).then((r) => r.data);
|
||||
api.patch<GroupOut>(`/admin/groups/${groupId}`, data);
|
||||
|
||||
export const adminDeleteGroup = (groupId: string) =>
|
||||
api.delete(`/admin/groups/${groupId}`);
|
||||
@@ -377,7 +488,10 @@ export const adminAddGroupMember = (groupId: string, userId: string) =>
|
||||
export const adminRemoveGroupMember = (groupId: string, userId: string) =>
|
||||
api.delete(`/admin/groups/${groupId}/members/${userId}`);
|
||||
|
||||
// --- Services ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Services
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ServiceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -387,10 +501,12 @@ export interface ServiceStatus {
|
||||
settings_path: string;
|
||||
}
|
||||
|
||||
export const getServices = () =>
|
||||
api.get<ServiceStatus[]>("/services").then((r) => r.data);
|
||||
export const getServices = () => api.get<ServiceStatus[]>("/services");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System Prompts (admin only)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- System Prompts (admin only) ---
|
||||
export interface ServiceSystemPrompt {
|
||||
label: string;
|
||||
system: string;
|
||||
@@ -402,17 +518,17 @@ export interface ServiceSystemPrompt {
|
||||
export type SystemPromptsData = Record<string, ServiceSystemPrompt>;
|
||||
|
||||
export const getSystemPrompts = () =>
|
||||
api.get<SystemPromptsData>("/settings/system-prompts").then((r) => r.data);
|
||||
api.get<SystemPromptsData>("/settings/system-prompts");
|
||||
|
||||
export const updateSystemPrompt = (
|
||||
serviceId: string,
|
||||
data: { system: string; user_template: string }
|
||||
) =>
|
||||
api
|
||||
.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data)
|
||||
.then((r) => r.data);
|
||||
) => api.patch<SystemPromptsData>(`/settings/system-prompts/${serviceId}`, data);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Document suggestions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// --- Document suggestions (watch-ingested documents) ---
|
||||
export const confirmFolderSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/folder/confirm`);
|
||||
|
||||
@@ -425,7 +541,10 @@ export const confirmFilenameSuggestion = (docId: string) =>
|
||||
export const rejectFilenameSuggestion = (docId: string) =>
|
||||
api.post(`/documents/${docId}/suggestions/filename/reject`);
|
||||
|
||||
// --- Plugins ---
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugins
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface PluginOut {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -456,14 +575,13 @@ export interface PluginManifest {
|
||||
};
|
||||
}
|
||||
|
||||
export const getPlugins = () =>
|
||||
api.get<PluginOut[]>("/plugins").then((r) => r.data);
|
||||
export const getPlugins = () => api.get<PluginOut[]>("/plugins");
|
||||
|
||||
export const getPluginManifest = (id: string) =>
|
||||
api.get<PluginManifest>(`/plugins/${id}/manifest`).then((r) => r.data);
|
||||
api.get<PluginManifest>(`/plugins/${id}/manifest`);
|
||||
|
||||
export const getPluginSettings = (id: string) =>
|
||||
api.get<Record<string, unknown>>(`/plugins/${id}/settings`).then((r) => r.data);
|
||||
api.get<Record<string, unknown>>(`/plugins/${id}/settings`);
|
||||
|
||||
export const updatePluginSettings = (id: string, data: Record<string, unknown>) =>
|
||||
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data).then((r) => r.data);
|
||||
api.patch<Record<string, unknown>>(`/plugins/${id}/settings`, data);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import axios from "axios";
|
||||
import { useState } from "react";
|
||||
import { Briefcase, LayoutDashboard } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -22,12 +21,8 @@ export default function LoginPage() {
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (err) {
|
||||
if (axios.isAxiosError(err)) {
|
||||
const detail = err.response?.data?.detail;
|
||||
setError(typeof detail === "string" ? detail : "Login failed.");
|
||||
} else {
|
||||
setError("Login failed.");
|
||||
}
|
||||
const detail = err instanceof Error ? err.message : "Login failed.";
|
||||
setError(detail);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
# Merge Checklist — Pre-merge Test Suite
|
||||
|
||||
Run all tests relevant to the changed area before merging any feature branch into `main`.
|
||||
Every test describes the exact UI action or API call to perform and the expected outcome.
|
||||
|
||||
**Test environment:** Feature stack at `http://localhost:$PORT` (see CLAUDE.md §Feature branch workflow).
|
||||
**Admin credentials:** any superuser account created during stack setup.
|
||||
**Regular user credentials:** a second non-admin account for permission boundary tests.
|
||||
|
||||
---
|
||||
|
||||
## Legend
|
||||
|
||||
| Symbol | Meaning |
|
||||
|--------|---------|
|
||||
| ✅ | Pass |
|
||||
| ❌ | Fail |
|
||||
| — | N/A for this change |
|
||||
|
||||
Mark each row before opening the PR.
|
||||
|
||||
---
|
||||
|
||||
## 1. Authentication
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 1.1 | Register new account | `POST /api/auth/register` with valid email + strong password | 201; user row created; login works immediately |
|
||||
| 1.2 | Password policy — too short | Register with 7-char password | 422 with validation error |
|
||||
| 1.3 | Password policy — no uppercase | Register with all-lowercase password | 422 with validation error |
|
||||
| 1.4 | Password policy — no special char | Register without special character | 422 with validation error |
|
||||
| 1.5 | Password policy — common word | Register with password containing "password" | 422 with validation error |
|
||||
| 1.6 | Duplicate email | Register with an already-used email | 400 |
|
||||
| 1.7 | Login — valid credentials | `POST /api/auth/login` with correct email + password | 200; `access_token` returned |
|
||||
| 1.8 | Login — wrong password | `POST /api/auth/login` with wrong password | 401 |
|
||||
| 1.9 | Login — inactive account | Admin deactivates user; attempt login | 401 |
|
||||
| 1.10 | JWT expiry respected | Manually craft token with `exp` in the past; call any protected route | 401 |
|
||||
| 1.11 | Logout clears session | Click Logout in UI; try navigating to `/` | Redirected to `/login` |
|
||||
| 1.12 | Unauthenticated redirect | Open `/` without a token in `localStorage` | Redirected to `/login` |
|
||||
|
||||
---
|
||||
|
||||
## 2. User — Profile & Preferences
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 2.1 | Fetch own profile | `GET /api/profile/me` | 200; profile auto-created if first request |
|
||||
| 2.2 | Update profile fields | `PUT /api/profile/me` with full_name, phone, position, address, date_of_birth | 200; fields persisted; visible on `/profile` page |
|
||||
| 2.3 | Invalid phone format | `PUT /api/profile/me` with letters in phone field | 422 |
|
||||
| 2.4 | Future date of birth | `PUT /api/profile/me` with DOB = tomorrow | 422 |
|
||||
| 2.5 | DOB before 1900 | `PUT /api/profile/me` with DOB = 1899-12-31 | 422 |
|
||||
| 2.6 | Fetch dashboard preferences | `GET /api/users/me/preferences` | 200; `app_ids` list |
|
||||
| 2.7 | Pin an app | Dashboard → pencil button → press `+` on a card → save | Card appears in pinned grid on next load |
|
||||
| 2.8 | Unpin an app | Dashboard → pencil button → press `−` on a pinned card → save | Card removed from pinned grid |
|
||||
| 2.9 | Pin limit (50) | `PATCH /api/users/me/preferences` with 51 app IDs | 422 |
|
||||
| 2.10 | Color mode — user pref | User sets mode to "dark"; reload page | Dark theme applied; preference persists across sessions |
|
||||
| 2.11 | Color mode — system fallback | User has NULL color_mode; admin default_mode = "light" | Light theme applied |
|
||||
|
||||
---
|
||||
|
||||
## 3. Admin — Users
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 3.1 | List all users | Admin → `/admin/users` | All registered users shown in table |
|
||||
| 3.2 | Create user | Admin clicks "Create user"; fills form | 201; new user appears in list; can log in |
|
||||
| 3.3 | Toggle user active | Admin clicks toggle on active user | User deactivated; login returns 401 |
|
||||
| 3.4 | Delete user | Admin deletes a user | 204; user no longer in list; their documents remain (orphaned) |
|
||||
| 3.5 | Non-admin access | Regular user navigates to `/admin/users` | Redirected to `/login` |
|
||||
| 3.6 | Admin 404 semantics | Regular user calls `GET /api/admin/users` via curl | 404 (not 403) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Admin — Groups
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 4.1 | List groups | Admin → `/admin/groups` | All groups shown with member count |
|
||||
| 4.2 | Create group | Fill name + description → submit | Group appears in list; `{group_name}-admin` bootstrap group also exists (auto-created on service start) |
|
||||
| 4.3 | Edit group | Click edit on group → change name → save | Name updated |
|
||||
| 4.4 | Delete group | Delete group | 204; group gone; memberships cascade-deleted |
|
||||
| 4.5 | Add member | Open group → search user → add | 204; user appears in member list |
|
||||
| 4.6 | Remove member | Click remove on a member | User removed from group |
|
||||
| 4.7 | Duplicate group name | Create group with name that already exists | 400 / validation error shown |
|
||||
| 4.8 | Non-admin access | Regular user calls `GET /api/admin/groups` | 404 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Admin — Appearance
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 5.1 | List themes | Admin → `/admin/appearance` | Built-in themes + any custom themes shown |
|
||||
| 5.2 | Switch active theme | Select a different theme → save | All users see the new theme on next load |
|
||||
| 5.3 | Create custom theme | Admin → create theme. Required fields: `id` (slug), `label`, `light` (CSS vars object), `dark` (CSS vars object) | 201; theme appears in selector; can be activated |
|
||||
| 5.4 | Edit custom theme | Admin edits colour values on a custom theme | Colours update live after activation |
|
||||
| 5.5 | Delete custom theme | Admin deletes a custom theme | 204; theme gone from selector; active theme reverts to default |
|
||||
| 5.6 | Set default mode | `PATCH /api/settings/appearance` with `{"theme": "<id>", "default_mode": "dark"}` (both fields required) | 200; new users without a personal preference see dark mode |
|
||||
|
||||
---
|
||||
|
||||
## 6. Service Health & Dashboard
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 6.1 | Services endpoint | `GET /api/services` (authenticated) | Returns health status for doc-service and ai-service |
|
||||
| 6.2 | Healthy service card | Both services running → `/apps` page. API response uses `healthy: true` (boolean), not `status: "healthy"` | Cards show "Available" badge; clicking navigates to the app |
|
||||
| 6.3 | Unhealthy service card | Stop doc-service container → wait 30s → `/apps` | Doc-service card dimmed, "Unavailable", not clickable |
|
||||
| 6.4 | Service recovery | Restart stopped container → wait 30s | Card returns to "Available" |
|
||||
| 6.5 | Dashboard pinned cards | Pin a service → go to `/` | Pinned card appears in home grid |
|
||||
| 6.6 | Customize mode | Click pencil on dashboard → toggle cards | Pinned list updates after save |
|
||||
|
||||
---
|
||||
|
||||
## 7. Plugin System
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 7.1 | List plugins | `GET /api/plugins` (authenticated) | Returns accessible plugins for current user |
|
||||
| 7.2 | Superuser sees all plugins | Log in as admin → `GET /api/plugins` | All registered service plugins returned |
|
||||
| 7.3 | Group member sees plugin | Add user to `doc-service-admin` group → `GET /api/plugins` | doc-service plugin returned |
|
||||
| 7.4 | Unpermitted user hidden | Regular user not in any admin group → `GET /api/plugins` | Empty list (plugins hidden, not 403) |
|
||||
| 7.5 | Manifest fetch | `GET /api/plugins/doc-service/manifest` as permitted user | JSON Schema + access rules returned |
|
||||
| 7.6 | Settings read | `GET /api/plugins/doc-service/settings` | Current doc-service plugin settings returned |
|
||||
| 7.7 | Settings write | `PATCH /api/plugins/doc-service/settings` with valid payload | 200; setting persisted to volume |
|
||||
| 7.8 | Unpermitted settings access | Regular user `GET /api/plugins/doc-service/settings` | 404 |
|
||||
|
||||
---
|
||||
|
||||
## 8. AI Service Settings
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 8.1 | Read AI config | Admin (or `ai-service-admin` member) → `GET /api/settings/ai` | Config returned; API keys masked |
|
||||
| 8.2 | Update provider | `PATCH /api/settings/ai` with provider = "anthropic" + valid key | 200; config persisted |
|
||||
| 8.3 | Test connection | `POST /api/settings/ai/test` with valid config | 200; success response from provider |
|
||||
| 8.4 | Test connection — bad key | `POST /api/settings/ai/test` with wrong API key | 502 or error detail |
|
||||
| 8.5 | Read system prompts | `GET /api/settings/system-prompts` | All registered service prompts returned |
|
||||
| 8.6 | Update system prompt | `PATCH /api/settings/system-prompts/doc-service` with new prompt text | 200; doc-service picks up new prompt within 30s |
|
||||
| 8.7 | Non-admin access | Regular user calls any `/api/settings/ai` endpoint | 404 |
|
||||
| 8.8 | `ai-service-admin` delegation | Non-superuser added to `ai-service-admin` group → accesses AI settings page | Page loads; can read and write settings |
|
||||
|
||||
---
|
||||
|
||||
## 9. Document Service Settings
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 9.1 | Read upload limits | `GET /api/settings/documents/limits` (admin or `doc-service-admin`) | `max_pdf_bytes` returned |
|
||||
| 9.2 | Update upload limit | `PATCH /api/settings/documents/limits` with new value | 200; upload of oversized PDF now rejected with 413 |
|
||||
| 9.3 | Non-admin access | Regular user calls `GET /api/settings/documents/limits` | 404 |
|
||||
| 9.4 | Settings page loads | Admin navigates to `/apps/documents/settings` | Upload limits section + watch directory config visible |
|
||||
| 9.5 | `doc-service-admin` delegation | Non-superuser added to `doc-service-admin` → navigates to settings page | Page loads; settings editable |
|
||||
|
||||
---
|
||||
|
||||
## 10. Document Upload & Processing
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 10.1 | Upload valid PDF | Drag-and-drop or file picker → select a PDF under the size limit | 202; document row appears with `status=pending`; transitions to `done` |
|
||||
| 10.2 | Upload oversized PDF | Upload a PDF exceeding `max_pdf_bytes` | 413; error shown; no row created |
|
||||
| 10.3 | Upload non-PDF | Upload a `.docx` or `.jpg` | 415; error shown |
|
||||
| 10.4 | Multi-file upload | Select 3 PDFs at once | All 3 appear in upload queue panel; each processes independently |
|
||||
| 10.5 | Upload queue panel | During upload → check bottom-right panel | Per-file status indicator; "Review →" link after each completes |
|
||||
| 10.6 | Drag-and-drop overlay | Drag file over the documents page | Full-page overlay appears; drop uploads file |
|
||||
| 10.7 | Processing status poll | Upload a large PDF | Table row auto-updates every 3s until status = `done` or `failed` |
|
||||
| 10.8 | AI extraction result | Open slide-over for a `done` document | title, document_type, tags, extracted_data fields populated |
|
||||
| 10.9 | Failed extraction | AI service down → upload PDF | Status = `failed`; error_message shown in slide-over |
|
||||
| 10.10 | Re-analyse | Click "Re-analyse" in slide-over | 202; status resets to `pending`; re-processes through AI |
|
||||
|
||||
---
|
||||
|
||||
## 11. Document List & Filtering
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 11.1 | Default list | Navigate to `/apps/documents` | Own documents shown, newest first, 20 per page |
|
||||
| 11.2 | Search | Type in search box (debounced 400ms) | Results filtered by title / filename / tags / type |
|
||||
| 11.3 | Filter by status | Add filter chip → Status → "done" | Only completed docs shown |
|
||||
| 11.4 | Filter by type | Add filter chip → Document type → "invoice" | Only invoices shown |
|
||||
| 11.5 | Filter by category | Add filter chip → Category → pick one | Only docs in that category shown |
|
||||
| 11.6 | Remove filter chip | Click × on a chip | Filter removed; full list restored |
|
||||
| 11.7 | Sort by column | Click "Date" column header | List re-ordered; chevron indicates direction; click again reverses |
|
||||
| 11.8 | Pagination | Upload > 20 docs → scroll to bottom | Page controls appear; page 2 loads next 20 |
|
||||
| 11.9 | "Mine" view | Click "Mine" in SourcePanel | Only own (uploaded) documents shown |
|
||||
| 11.10 | "Shared with me" view | Click "Shared with me" | Docs shared by others via groups; own docs excluded |
|
||||
| 11.11 | Category filter via SourcePanel | Click a category in the left tree | Table filtered to that category's documents |
|
||||
| 11.12 | URL state preserved | Apply filters → copy URL → open in new tab | Same filters applied |
|
||||
|
||||
---
|
||||
|
||||
## 12. Document Detail — Slide-over
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 12.1 | Open slide-over | Click any document row | 480px right panel slides in; metadata loaded |
|
||||
| 12.2 | Inline title edit | Click pencil icon next to title → type new title → confirm | Title saved; updated in table row |
|
||||
| 12.3 | Change document type | Click a type chip (Invoice, Receipt, etc.) | Type updated immediately |
|
||||
| 12.4 | Edit tags | Click into tag area → type a tag → press Enter → remove a tag with × | Tags saved correctly |
|
||||
| 12.5 | Assign category | Categories combobox → search → select | Category badge appears on document; table row updates |
|
||||
| 12.6 | Remove category | Click × on an assigned category badge | Category removed from document |
|
||||
| 12.7 | AI category suggestions | Slide-over shows "Suggested categories" | "Assign" and "Create & Assign" buttons present; clicking assigns |
|
||||
| 12.8 | Confirm folder suggestion | "Confirm" button next to suggested_folder | Category created (if needed) and assigned; `suggested_folder` cleared |
|
||||
| 12.9 | Reject folder suggestion | "Reject" button next to suggested_folder | `suggested_folder` cleared; no category created |
|
||||
| 12.10 | Confirm filename suggestion | "Confirm" button next to suggested_filename | `title` updated to suggested value; `suggested_filename` cleared |
|
||||
| 12.11 | Reject filename suggestion | "Reject" button next to suggested_filename | `suggested_filename` cleared; title unchanged |
|
||||
| 12.12 | Extracted data section | Open slide-over on `done` doc | Key-value table of AI-extracted fields (vendor, amounts, dates, etc.) |
|
||||
| 12.13 | Raw text section | Expand raw text collapse | First ~500k chars of extracted PDF text shown |
|
||||
| 12.14 | Download | Click "Download" | Browser downloads the original PDF file |
|
||||
| 12.15 | View in new tab | Click "View" | PDF opens in new browser tab; URL auto-revokes after 60s |
|
||||
| 12.16 | Delete | Click "Delete" → confirm dialog | Document and file removed; table row gone |
|
||||
| 12.17 | Non-owner cannot edit | Recipient of shared doc opens slide-over | Edit controls (type, tags, title, delete) absent; download available |
|
||||
|
||||
---
|
||||
|
||||
## 13. Document Sharing
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 13.1 | Share from slide-over | Owner opens sharing section → selects a group from combobox → shares | Group appears in shares list; `share_count` in table row increments |
|
||||
| 13.2 | Only user's own groups shown | Open group picker in share section | Only groups the current user belongs to are listed |
|
||||
| 13.3 | Recipient sees shared doc | Log in as group member → "Shared with me" view | Shared document appears with primary accent border |
|
||||
| 13.4 | Recipient download | Recipient clicks Download on shared doc | PDF downloaded successfully |
|
||||
| 13.4b | Non-owner calls `GET /documents/{id}/shares` | Regular user on a doc they don't own | 404 (doc-service hides existence, consistent with admin 404 semantics — not 403) |
|
||||
| 13.5 | Recipient cannot delete | Recipient opens slide-over | Delete button absent |
|
||||
| 13.6 | Recipient cannot re-share | Recipient opens sharing section | Share controls absent |
|
||||
| 13.7 | Remove share | Owner clicks remove on a group share | Group removed; `share_count` decrements; recipient no longer sees doc |
|
||||
| 13.8 | Bulk share | Select multiple rows → bulk share → pick group | All selected docs shared with that group |
|
||||
| 13.9 | Share count indicator | Document shared with 2 groups | Users icon in table row shows "2" |
|
||||
| 13.10 | Share with non-member group | `POST /api/documents/{id}/shares` with group not in X-User-Groups | 403 / validation error |
|
||||
|
||||
---
|
||||
|
||||
## 14. Categories
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 14.1 | Create category | SourcePanel → "New category" form → submit | Category appears in tree |
|
||||
| 14.2 | Rename category | Manage categories dialog → edit → save | New name reflected everywhere |
|
||||
| 14.3 | Delete category | Delete category with documents assigned | 204; documents remain; category assignment removed |
|
||||
| 14.4 | Category search | More than 4 categories → type in search field | Tree filtered in real time |
|
||||
| 14.5 | Manage categories dialog | Click "Manage categories" | Modal shows all categories with rename/delete actions |
|
||||
| 14.6 | New category triggers re-analysis | Create category with name similar to AI suggestion | Background re-analysis triggered (check backend logs) |
|
||||
|
||||
---
|
||||
|
||||
## 15. Bulk Actions
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 15.1 | Select rows | Tick checkboxes on multiple rows | Floating bulk actions bar appears at bottom |
|
||||
| 15.2 | Bulk share | Select docs → Share with group → confirm | All selected docs shared; confirmation |
|
||||
| 15.3 | Bulk delete | Select docs → Delete → confirm dialog | All selected docs deleted; bar disappears |
|
||||
| 15.4 | Clear selection | Click "Clear" in bulk bar | All checkboxes deselected; bar hides |
|
||||
| 15.5 | Bulk bar — "Mine" view only | Switch to "Shared with me" view | Bulk actions bar not shown (no edit rights for shared docs) |
|
||||
|
||||
---
|
||||
|
||||
## 16. Watch Directory
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 16.1 | Enable watch | Doc settings page → toggle `watch_enabled` on → save | File watcher starts; backend logs confirm |
|
||||
| 16.2 | Ingest new file | Drop a PDF into the bind-mounted watch directory | Document appears in "All Documents" view with `source=watch` |
|
||||
| 16.3 | Sub-folder to category | Place PDF in `watch/invoices/` | Document auto-assigned to "invoices" category |
|
||||
| 16.4 | Startup scan | Restart doc-service with PDFs already in watch dir | Pre-existing PDFs ingested (idempotent — no duplicates) |
|
||||
| 16.5 | AI folder suggestion | `ai_folder_suggestion` enabled → ingest file | `suggested_folder` populated; confirm/reject buttons visible in slide-over |
|
||||
| 16.6 | AI rename suggestion | `ai_rename_suggestion` enabled → ingest file | `suggested_filename` populated; confirm/reject buttons visible |
|
||||
| 16.7 | No-remove policy | Delete PDF from watch dir | Document record remains in DB |
|
||||
| 16.8 | Disable watch | Toggle `watch_enabled` off → save | Watcher stops; new files dropped are not ingested |
|
||||
| 16.9 | Watch docs visible to all users | Log in as any authenticated user | Watch-ingested docs (`user_id = "watch"`) appear in "All Documents" |
|
||||
|
||||
---
|
||||
|
||||
## 17. AI Service — Queue & Providers
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 17.1 | Health check | `GET /health` on ai-service (via backend services endpoint) | `{"status": "ok"}` |
|
||||
| 17.2 | Provider health | `GET /health/provider` | Active provider name, model, configured=true |
|
||||
| 17.3 | Unconfigured provider | Set provider to "anthropic" with empty API key → test connection | 503 or 502 with clear error |
|
||||
| 17.4 | Sync chat | `POST /chat` with valid messages array | Response returned synchronously |
|
||||
| 17.5 | Queue — async job | `POST /queue/jobs` | `job_id` returned immediately |
|
||||
| 17.6 | Queue — poll job | `GET /queue/jobs/{id}` after enqueue | Returns status (`pending` → `done`) and result |
|
||||
| 17.7 | Queue — cancel job | `DELETE /queue/jobs/{id}` before processing | Job removed; status = cancelled |
|
||||
| 17.8 | Queue pause | `POST /queue/pause` | 204; current job finishes; no new jobs picked up |
|
||||
| 17.9 | Queue resume | `POST /queue/resume` after pause | 204; worker resumes; pending jobs process |
|
||||
| 17.10 | Priority ordering | Enqueue LOW then HIGH job | HIGH job processed first |
|
||||
| 17.11 | Provider timeout | `POST /chat` when provider is unreachable | 504 returned after timeout |
|
||||
|
||||
---
|
||||
|
||||
## 18. Infrastructure & Security
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 18.1 | Non-root containers | `docker inspect <container> --format '{{.Config.User}}'` for each service | Returns `1001` (or `70` for db) |
|
||||
| 18.2 | No host ports in prod | `docker compose up --build -d` → `docker ps` | Only port 80 (frontend) exposed; no 8000/8001/8010/5432 |
|
||||
| 18.3 | backend-net isolation | `curl http://localhost:8000` from host in prod | Connection refused |
|
||||
| 18.4 | Pre-commit hook runs | Stage a file with `eval("x")` → `git commit` | Commit blocked; security_check.py output shown |
|
||||
| 18.5 | Pre-commit hook — clean code | Normal commit | Hook passes; commit succeeds |
|
||||
| 18.6 | JWT algorithm none rejected | Craft token with `"alg": "none"` → call protected route | 401 |
|
||||
| 18.7 | XSS — input sanitation | Enter `<script>alert(1)</script>` in title/name fields | Value stored as plain text; not executed in UI |
|
||||
| 18.8 | SQL injection attempt | Pass `'; DROP TABLE documents; --` as search param | 200 with empty results; no DB error |
|
||||
| 18.9 | CORS | `curl -H "Origin: http://evil.com" http://localhost/api/users/me` | Request blocked or `access-control-allow-origin` not set for that origin |
|
||||
| 18.10 | Config volume persistence | Restart all containers | AI provider config + doc limits survive restart |
|
||||
| 18.11 | Migration auto-apply | Start fresh stack | Both `alembic upgrade head` chains run without error; all tables created |
|
||||
|
||||
---
|
||||
|
||||
## 19. Frontend — UI & Routing
|
||||
|
||||
| # | Test | Steps | Expected |
|
||||
|---|------|-------|----------|
|
||||
| 19.1 | PrivateRoute redirect | Open any protected route without token | Redirected to `/login` |
|
||||
| 19.2 | AdminRoute redirect | Log in as non-admin → navigate to `/admin` | Redirected to `/login` |
|
||||
| 19.3 | ServiceAdminRoute | Non-admin, non-group-member → navigate to `/apps/documents/settings` | Redirected (access denied) |
|
||||
| 19.4 | Sidebar collapse | Click collapse button | Sidebar shrinks to icon-only; expand restores labels |
|
||||
| 19.5 | Apps accordion | Click "Apps" in sidebar | Expands to show "Documents" NavLink |
|
||||
| 19.6 | SourcePanel visibility | Navigate to `/apps` then `/apps/documents` | SourcePanel only visible on `/apps/documents` route |
|
||||
| 19.7 | Theme toggle | Click sun/moon button | Mode switches; persists on reload |
|
||||
| 19.8 | Unknown route | Navigate to `/does-not-exist` | Redirected to `/` |
|
||||
| 19.9 | TanStack Query cache | Navigate away from docs → back | List loads from cache instantly; background refetch runs |
|
||||
| 19.10 | 30s service poll | Leave `/apps` open for 30s | `GET /api/services` fires again in network tab |
|
||||
Reference in New Issue
Block a user