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:
+37
-1
@@ -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]}
|
||||
|
||||
@@ -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
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user