diff --git a/app/api/user.py b/app/api/user.py index 3e4840a1..4fa0ce36 100644 --- a/app/api/user.py +++ b/app/api/user.py @@ -4,14 +4,23 @@ User-related API endpoints import logging from hashlib import md5 +from typing import Annotated -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.auth import require_login +from app.database import get_db +from app.models import FileRecord # Set up logging logger = logging.getLogger(__name__) router = APIRouter() +DbSession = Annotated[Session, Depends(get_db)] + async def whoami_handler(request: Request): """ @@ -46,3 +55,30 @@ async def whoami(request: Request): @router.get("/auth/whoami") async def auth_whoami(request: Request): return await whoami_handler(request) + + +@router.get("/users/search") +@require_login +def search_known_users( + db: DbSession, + q: str = Query("", description="Substring to match against known owner IDs"), + limit: int = Query(5, ge=1, le=20, description="Maximum number of results"), +): + """ + Search known user identifiers (owner_ids) from existing documents. + + Returns distinct ``owner_id`` values from the files table that contain + the query string as a case-insensitive substring. Results are limited + to at most ``limit`` entries (default 5). + + This powers the autocomplete widget on the settings page for the + ``default_owner_id`` field. + """ + base_query = db.query(FileRecord.owner_id).filter(FileRecord.owner_id.isnot(None)).distinct() + + if q.strip(): + base_query = base_query.filter(func.lower(FileRecord.owner_id).contains(q.strip().lower())) + + results = base_query.order_by(FileRecord.owner_id).limit(limit).all() + + return {"users": [row[0] for row in results]} diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 5ef0f8ac..eee9701d 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -116,9 +116,10 @@ SETTING_METADATA = { "category": "Authentication", "description": ( "Automatically assigns this owner ID to newly ingested documents that would otherwise " - "have no owner. Leave empty to keep documents unowned until claimed." + "have no owner. Start typing to search existing users, or leave empty to keep documents " + "unowned until claimed." ), - "type": "string", + "type": "user_autocomplete", "sensitive": False, "required": False, "restart_required": False, diff --git a/docs/API.md b/docs/API.md index 304345b0..04a4c45b 100644 --- a/docs/API.md +++ b/docs/API.md @@ -614,6 +614,118 @@ curl -X POST "http:///api/files/bulk-download" \ **Error Responses**: - `404`: No files found with the provided IDs, or none of the selected files exist on disk +### Document Ownership (Multi-User Mode) + +These endpoints are available when `MULTI_USER_ENABLED=true`. + +--- + +**POST** `/api/files/{file_id}/claim` + +Claim an unclaimed document (owner_id is NULL) for the current user. + +```bash +curl -X POST "http:///api/files/42/claim" +``` + +**Response**: +```json +{ + "status": "success", + "message": "Document claimed successfully", + "file_id": 42, + "owner_id": "alice@example.com" +} +``` + +**Error Responses**: +- `400`: Multi-user mode is not enabled +- `401`: Authentication required +- `403`: Document is already owned by another user + +--- + +**POST** `/api/files/bulk-claim` + +Claim multiple unclaimed documents at once. Already-owned documents are skipped. + +**Request body**: JSON array of file IDs + +```bash +curl -X POST "http:///api/files/bulk-claim" \ + -H "Content-Type: application/json" \ + -d '[1, 2, 3]' +``` + +**Response**: +```json +{ + "status": "success", + "claimed_count": 2, + "claimed_ids": [1, 3], + "skipped": [{"file_id": 2, "reason": "already owned"}], + "owner_id": "alice@example.com" +} +``` + +--- + +**POST** `/api/files/assign-owner` + +**Admin only.** Assign an owner to documents. If `file_ids` body is omitted, assigns all +currently unclaimed documents to the specified owner. + +**Query Parameters**: +- `owner_id` (required): The user identifier to assign + +**Request body** (optional): JSON array of specific file IDs + +```bash +# Assign all unclaimed documents to a user +curl -X POST "http:///api/files/assign-owner?owner_id=alice@example.com" + +# Assign specific files +curl -X POST "http:///api/files/assign-owner?owner_id=alice@example.com" \ + -H "Content-Type: application/json" \ + -d '[1, 2, 3]' +``` + +**Response**: +```json +{ + "status": "success", + "message": "Assigned owner to 5 document(s)", + "updated_count": 5, + "owner_id": "alice@example.com" +} +``` + +**Error Responses**: +- `400`: Multi-user mode is not enabled +- `403`: Only admins can assign document owners + +--- + +**GET** `/api/users/search` + +Search known user identifiers from existing documents. Powers the autocomplete widget +in the settings page for the `DEFAULT_OWNER_ID` field. + +**Query Parameters**: +- `q` (optional): Substring to match against known owner IDs (case-insensitive) +- `limit` (optional): Maximum results to return (default: 5, max: 20) + +```bash +curl "http:///api/users/search?q=risti&limit=5" +``` + +**Response**: +```json +{ + "users": ["christianlouis"] +} +``` + ### File Preview **GET** `/api/files/{file_id}/preview` diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 88973e87..9650c7d3 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -163,6 +163,37 @@ Requires `AUTH_ENABLED=true`. |-----------------------------|---------------------------------------------------------------------------------|-------------| | `MULTI_USER_ENABLED` | Enable multi-user mode with individual document spaces per user. | `false` | | `DEFAULT_DAILY_UPLOAD_LIMIT`| Maximum document uploads allowed per user per day. `0` = unlimited. | `0` | +| `UNOWNED_DOCS_VISIBLE_TO_ALL` | Show unclaimed documents (no owner) to all users. When `false`, only admins see them. | `true` | +| `DEFAULT_OWNER_ID` | Automatically assign this owner to newly ingested documents without a session (e.g. IMAP, API). Leave empty to keep unowned. | *(empty)* | + +#### Unclaimed Documents + +Documents ingested without a user session (e.g. via IMAP polling, API calls without authentication, +or legacy imports) have `owner_id = NULL`. These are called **unclaimed** documents. + +- When `UNOWNED_DOCS_VISIBLE_TO_ALL=true` (default), every authenticated user sees unclaimed + documents alongside their own files. This allows users to discover and claim them. +- When `UNOWNED_DOCS_VISIBLE_TO_ALL=false`, only admins can see unclaimed documents. + +#### Claiming Documents + +Users can claim unclaimed documents via the API: + +- **`POST /api/files/{file_id}/claim`** — Claim a single unclaimed document. +- **`POST /api/files/bulk-claim`** — Claim multiple unclaimed documents at once. + +Only documents with `owner_id = NULL` can be claimed. Already-owned documents cannot be claimed +by another user. + +#### Admin Owner Assignment + +Admins can assign ownership of documents to any user: + +- **`POST /api/files/assign-owner?owner_id=`** — Assign all unclaimed documents to + the specified user, or pass a `file_ids` JSON body to assign specific files. + +The `DEFAULT_OWNER_ID` setting can also be configured via the Settings page, which provides an +autocomplete field that searches existing users by substring. ### Security Headers diff --git a/docs/SettingsManagement.md b/docs/SettingsManagement.md index ee310826..705e9690 100644 --- a/docs/SettingsManagement.md +++ b/docs/SettingsManagement.md @@ -48,6 +48,7 @@ Settings are organized into logical categories for easy navigation: - **Dropdown**: Predefined option lists (e.g., PDF/A format, S3 storage class, S3 ACL) - **Multi-select**: Comma-separated selections from a list (e.g., OCR providers) - **Model Picker**: Free-text with suggested model names (e.g., AI model selection) +- **User Autocomplete**: Typeahead search for existing user identifiers (e.g., default owner assignment) - **List**: Comma-separated values (notification URLs, CORS origins) ### Sensitive Data diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index f63ac4f9..ac5754a7 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -290,6 +290,74 @@ {% endfor %}

Effective value:

+ {% elif setting.metadata.type == 'user_autocomplete' %} + +
+
+ +
+ +
+
+ +
+
+ +
    + +
+ +
+ No matching users found +
+

+ + Type to search existing users by name, or enter any identifier manually. +

+
{% elif setting.metadata.type == 'model_picker' %}
@@ -712,5 +780,63 @@ function settingsApp() { } }; } + +/** + * Alpine.js component for the user-autocomplete setting widget. + * Fetches matching owner_id values from GET /api/users/search?q=&limit=5 + * and displays them in a dropdown list. + */ +function userAutocomplete(settingKey) { + return { + suggestions: [], + showSuggestions: false, + loading: false, + searchDone: false, + highlightedIdx: -1, + + async fetchSuggestions(query) { + this.loading = true; + this.searchDone = false; + this.highlightedIdx = -1; + try { + const resp = await fetch('/api/users/search?q=' + encodeURIComponent(query || '') + '&limit=5'); + if (resp.ok) { + const data = await resp.json(); + this.suggestions = data.users || []; + } else { + this.suggestions = []; + } + } catch { + this.suggestions = []; + } + this.loading = false; + this.searchDone = true; + this.showSuggestions = true; + }, + + selectUser(user) { + this.formData[settingKey] = user; + this.showSuggestions = false; + }, + + highlightNext() { + if (this.suggestions.length === 0) return; + this.highlightedIdx = (this.highlightedIdx + 1) % this.suggestions.length; + }, + + highlightPrev() { + if (this.suggestions.length === 0) return; + this.highlightedIdx = this.highlightedIdx <= 0 ? this.suggestions.length - 1 : this.highlightedIdx - 1; + }, + + selectHighlighted() { + if (this.highlightedIdx >= 0 && this.highlightedIdx < this.suggestions.length) { + this.selectUser(this.suggestions[this.highlightedIdx]); + } else { + this.showSuggestions = false; + } + } + }; +} {% endblock %} diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index 14e32501..f1e4c6cc 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -453,7 +453,7 @@ class TestUnownedDocsConfig: assert "default_owner_id" in SETTING_METADATA meta = SETTING_METADATA["default_owner_id"] - assert meta["type"] == "string" + assert meta["type"] == "user_autocomplete" # --------------------------------------------------------------------------- @@ -615,3 +615,70 @@ class TestAssignOwnerUnit: mu_session.refresh(rec2) assert rec1.owner_id == "charlie" assert rec2.owner_id == "charlie" + + +# --------------------------------------------------------------------------- +# User search endpoint tests +# --------------------------------------------------------------------------- + + +class TestUserSearchEndpoint: + """Tests for GET /api/users/search.""" + + @pytest.mark.integration + def test_search_returns_known_users(self, client, db_session): + """Search should return distinct owner_ids from file records.""" + _create_file_record(db_session, owner_id="alice", filename="a.pdf") + _create_file_record(db_session, owner_id="bob", filename="b.pdf") + _create_file_record(db_session, owner_id="alice", filename="a2.pdf") # duplicate owner + _create_file_record(db_session, owner_id=None, filename="c.pdf") # unowned + + response = client.get("/api/users/search?q=") + assert response.status_code == 200 + data = response.json() + assert "users" in data + # Should contain alice and bob (not None) + assert set(data["users"]) == {"alice", "bob"} + + @pytest.mark.integration + def test_search_filters_by_substring(self, client, db_session): + """Search should filter by case-insensitive substring.""" + _create_file_record(db_session, owner_id="christianlouis", filename="a.pdf") + _create_file_record(db_session, owner_id="bob", filename="b.pdf") + _create_file_record(db_session, owner_id="alice", filename="c.pdf") + + response = client.get("/api/users/search?q=risti") + assert response.status_code == 200 + data = response.json() + assert data["users"] == ["christianlouis"] + + @pytest.mark.integration + def test_search_respects_limit(self, client, db_session): + """Search should respect the limit parameter.""" + for i in range(10): + _create_file_record(db_session, owner_id=f"user_{i:02d}", filename=f"file_{i}.pdf") + + response = client.get("/api/users/search?q=user&limit=3") + assert response.status_code == 200 + data = response.json() + assert len(data["users"]) == 3 + + @pytest.mark.integration + def test_search_empty_when_no_matches(self, client, db_session): + """Search with no matches should return empty list.""" + _create_file_record(db_session, owner_id="alice", filename="a.pdf") + + response = client.get("/api/users/search?q=zzzzz") + assert response.status_code == 200 + data = response.json() + assert data["users"] == [] + + @pytest.mark.integration + def test_search_case_insensitive(self, client, db_session): + """Search should be case-insensitive.""" + _create_file_record(db_session, owner_id="ChristianLouis", filename="a.pdf") + + response = client.get("/api/users/search?q=CHRISTIAN") + assert response.status_code == 200 + data = response.json() + assert data["users"] == ["ChristianLouis"]