feat(ui): add user autocomplete widget for default_owner_id, user search API, and documentation

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-05 21:40:41 +00:00
parent 5722252dcb
commit 3601e2ca5c
7 changed files with 378 additions and 4 deletions
+37 -1
View File
@@ -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]}
+3 -2
View File
@@ -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,
+112
View File
@@ -614,6 +614,118 @@ curl -X POST "http://<your-instance>/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://<your-instance>/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://<your-instance>/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://<your-instance>/api/files/assign-owner?owner_id=alice@example.com"
# Assign specific files
curl -X POST "http://<your-instance>/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://<your-instance>/api/users/search?q=risti&limit=5"
```
**Response**:
```json
{
"users": ["christianlouis"]
}
```
### File Preview
**GET** `/api/files/{file_id}/preview`
+31
View File
@@ -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=<user_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
+1
View File
@@ -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
+126
View File
@@ -290,6 +290,74 @@
{% endfor %}
</div>
<p class="mt-1 text-xs text-gray-400">Effective value: <code x-text="formData['{{ setting.key }}'] || '(none)'"></code></p>
{% elif setting.metadata.type == 'user_autocomplete' %}
<!-- User Autocomplete -->
<div class="relative" x-data="userAutocomplete('{{ setting.key }}')" @click.away="showSuggestions = false">
<div class="relative">
<input
type="text"
id="{{ setting.key }}"
name="{{ setting.key }}"
x-model="formData['{{ setting.key }}']"
@input.debounce.250ms="fetchSuggestions($event.target.value)"
@focus="if (formData['{{ setting.key }}']) fetchSuggestions(formData['{{ setting.key }}']); else fetchSuggestions('')"
@keydown.arrow-down.prevent="highlightNext()"
@keydown.arrow-up.prevent="highlightPrev()"
@keydown.enter.prevent="selectHighlighted()"
@keydown.escape="showSuggestions = false"
class="setting-input w-full px-3 py-2 pl-9 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="Start typing to search users…"
autocomplete="off"
role="combobox"
aria-autocomplete="list"
:aria-expanded="showSuggestions && suggestions.length > 0"
aria-haspopup="listbox"
:aria-activedescendant="highlightedIdx >= 0 ? '{{ setting.key }}_opt_' + highlightedIdx : ''"
/>
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<i class="fas fa-user text-gray-400 text-sm" aria-hidden="true"></i>
</div>
<div x-show="loading" class="absolute inset-y-0 right-0 flex items-center pr-3">
<i class="fas fa-spinner fa-spin text-gray-400 text-sm" aria-hidden="true"></i>
</div>
</div>
<!-- Suggestion dropdown -->
<ul
x-show="showSuggestions && suggestions.length > 0"
x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 -translate-y-1"
x-transition:enter-end="opacity-100 translate-y-0"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-y-auto"
role="listbox"
:id="'{{ setting.key }}_listbox'"
>
<template x-for="(user, idx) in suggestions" :key="user">
<li
:id="'{{ setting.key }}_opt_' + idx"
role="option"
:aria-selected="highlightedIdx === idx"
@mouseenter="highlightedIdx = idx"
@click="selectUser(user)"
class="px-3 py-2 text-sm cursor-pointer flex items-center gap-2"
:class="highlightedIdx === idx ? 'bg-blue-50 text-blue-700' : 'text-gray-700 hover:bg-gray-50'"
>
<i class="fas fa-user-circle text-gray-400" aria-hidden="true"></i>
<span x-text="user"></span>
</li>
</template>
</ul>
<!-- Empty state -->
<div
x-show="showSuggestions && suggestions.length === 0 && !loading && searchDone"
class="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-md shadow-lg px-3 py-2 text-sm text-gray-500"
>
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i> No matching users found
</div>
<p class="text-xs text-gray-400 mt-1">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
Type to search existing users by name, or enter any identifier manually.
</p>
</div>
{% elif setting.metadata.type == 'model_picker' %}
<!-- Model Picker -->
<div class="relative">
@@ -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=<term>&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;
}
}
};
}
</script>
{% endblock %}
+68 -1
View File
@@ -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"]