From af4cd966e9f4d0cae18e683de99ae2a68cc08b67 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:14:38 +0000 Subject: [PATCH 01/10] Initial plan From 71f437e43aa95788d2dc8c57725fa3842816ad96 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:26:24 +0000 Subject: [PATCH 02/10] feat(multi-user): add multi-user feature flag, owner_id model field, and user-scoped queries Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .env.demo | 7 ++ app/api/files.py | 36 +++++++--- app/config.py | 19 +++++ app/models.py | 5 ++ app/tasks/convert_to_pdf.py | 9 ++- app/tasks/process_document.py | 11 ++- app/utils/settings_service.py | 22 ++++++ app/utils/user_scope.py | 72 +++++++++++++++++++ .../versions/012_add_multi_user_support.py | 29 ++++++++ 9 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 app/utils/user_scope.py create mode 100644 migrations/versions/012_add_multi_user_support.py diff --git a/.env.demo b/.env.demo index 0e4f2934..095a9dc3 100644 --- a/.env.demo +++ b/.env.demo @@ -129,6 +129,13 @@ ADMIN_USERNAME=admin ADMIN_PASSWORD=your_secure_password ADMIN_GROUP_NAME=admin +# **Multi-User Mode** +# When enabled, each user has their own document space with isolated uploads, +# search, and file management. Requires AUTH_ENABLED=true. +MULTI_USER_ENABLED=false +# Default upload limit per user per day (0 = unlimited) +DEFAULT_DAILY_UPLOAD_LIMIT=0 + # **OpenID Connect/Authentik Settings** AUTHENTIK_CLIENT_ID= AUTHENTIK_CLIENT_SECRET= diff --git a/app/api/files.py b/app/api/files.py index 107fc8c7..4d7fb078 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -99,8 +99,11 @@ def list_files_api( validate_sort_order(sort_order) search = validate_search_query(search) - # Start with base query + # Start with base query, scoped to the current user in multi-user mode + from app.utils.user_scope import apply_owner_filter + query = db.query(FileRecord) + query = apply_owner_filter(query, request) # Apply search filter if search: @@ -241,8 +244,12 @@ def get_file_details(request: Request, file_id: int, db: DbSession): """ Get detailed information about a specific file including processing history. """ - # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + # Find the file record, scoped to the current user in multi-user mode + from app.utils.user_scope import apply_owner_filter + + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found") @@ -300,8 +307,12 @@ def delete_file_record(request: Request, file_id: int, db: DbSession): raise HTTPException(status_code=403, detail="File deletion is disabled in the configuration") try: - # Find the file record - file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + # Find the file record, scoped to the current user in multi-user mode + from app.utils.user_scope import apply_owner_filter + + query = db.query(FileRecord).filter(FileRecord.id == file_id) + query = apply_owner_filter(query, request) + file_record = query.first() if not file_record: raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found") @@ -1286,6 +1297,11 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... mime_type, _ = mimetypes.guess_type(target_path) file_ext = os.path.splitext(target_path)[1].lower() + # Determine the owner_id for multi-user document isolation + from app.utils.user_scope import get_current_owner_id + + upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None + # Check if it's a PDF by extension or MIME type is_pdf = file_ext == ".pdf" or mime_type == "application/pdf" @@ -1310,7 +1326,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... task_ids = [] for split_file in split_files: split_filename = os.path.basename(split_file) - task = process_document.delay(split_file, original_filename=split_filename) + task = process_document.delay(split_file, original_filename=split_filename, owner_id=upload_owner_id) task_ids.append(task.id) logger.info(f"Enqueued split PDF part for processing: {split_file}") @@ -1333,7 +1349,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... if is_pdf and not should_split: # If it's a PDF, process directly - task = process_document.delay(target_path, original_filename=safe_filename) + task = process_document.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) logger.info(f"Enqueued PDF for processing: {target_path}") elif mime_type in IMAGE_MIME_TYPES or file_ext in { ".jpg", @@ -1347,16 +1363,16 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... ".svg", }: # If it's an image, convert to PDF first - task = convert_to_pdf.delay(target_path, original_filename=safe_filename) + task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) logger.info(f"Enqueued image for PDF conversion: {target_path}") elif mime_type in ALLOWED_MIME_TYPES or file_ext in ALLOWED_EXTENSIONS: # Office document, HTML, Markdown, or other Gotenberg-supported format - task = convert_to_pdf.delay(target_path, original_filename=safe_filename) + task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) logger.info(f"Enqueued document for PDF conversion: {target_path}") else: # For any other file type, attempt conversion but log a warning logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion") - task = convert_to_pdf.delay(target_path, original_filename=safe_filename) + task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id) # Check for exact duplicates (same SHA-256 hash) before returning. # This gives the caller an immediate warning without waiting for the pipeline. diff --git a/app/config.py b/app/config.py index b0bae60f..4e9c302c 100644 --- a/app/config.py +++ b/app/config.py @@ -116,6 +116,25 @@ class Settings(BaseSettings): session_secret: Optional[str] = None admin_group_name: str = "admin" + # Multi-user settings + multi_user_enabled: bool = Field( + default=False, + description=( + "Enable multi-user mode with individual document spaces per user. " + "When enabled, each authenticated user sees only their own documents, " + "uploads, and search results. Shared settings (AI, OCR) remain global. " + "Requires auth_enabled=True. Default: False (single-user/shared mode)." + ), + ) + default_daily_upload_limit: int = Field( + default=0, + description=( + "Default maximum number of document uploads allowed per user per day " + "in multi-user mode. Set to 0 for unlimited. " + "Individual user limits can override this default. Default: 0 (unlimited)." + ), + ) + # Authentik authentik_client_id: Optional[str] = None authentik_client_secret: Optional[str] = None diff --git a/app/models.py b/app/models.py index a14d5566..b1350f05 100644 --- a/app/models.py +++ b/app/models.py @@ -24,6 +24,11 @@ class FileRecord(Base): id = Column(Integer, primary_key=True, index=True) + # Owner identifier for multi-user mode. + # Stores the user's unique identifier (e.g. email or OAuth sub claim). + # NULL means the file belongs to the shared/global space (single-user mode). + owner_id = Column(String, nullable=True, index=True) + # Hash of the file content (e.g. SHA-256) # Note: duplicates are allowed so filehash is not unique filehash = Column(String, index=True, nullable=False) diff --git a/app/tasks/convert_to_pdf.py b/app/tasks/convert_to_pdf.py index 29d404ce..2a0ff4ac 100644 --- a/app/tasks/convert_to_pdf.py +++ b/app/tasks/convert_to_pdf.py @@ -124,7 +124,9 @@ def _build_filename(file_path: str, original_filename: Optional[str], file_ext: @shared_task(bind=True) -def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None) -> Optional[str]: +def convert_to_pdf( + self, file_path: str, original_filename: Optional[str] = None, owner_id: Optional[str] = None +) -> Optional[str]: """ Converts a file to PDF using Gotenberg's API. Determines the appropriate Gotenberg endpoint based on the file's MIME type. @@ -133,6 +135,7 @@ def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None Args: file_path: Path to the file to convert original_filename: Optional original filename (if different from path basename) + owner_id: Optional user identifier forwarded to process_document for multi-user mode. """ task_id = self.request.id logger.info(f"[{task_id}] Starting PDF conversion: {file_path}") @@ -332,9 +335,9 @@ def convert_to_pdf(self, file_path: str, original_filename: Optional[str] = None # Change extension to .pdf for the original filename original_base = os.path.splitext(original_filename)[0] pdf_original_filename = f"{original_base}.pdf" - process_document.delay(converted_file_path, original_filename=pdf_original_filename) + process_document.delay(converted_file_path, original_filename=pdf_original_filename, owner_id=owner_id) else: - process_document.delay(converted_file_path) + process_document.delay(converted_file_path, owner_id=owner_id) return converted_file_path else: diff --git a/app/tasks/process_document.py b/app/tasks/process_document.py index dab68d49..c6141a8f 100644 --- a/app/tasks/process_document.py +++ b/app/tasks/process_document.py @@ -25,7 +25,12 @@ logger = logging.getLogger(__name__) @celery.task(base=BaseTaskWithRetry, bind=True) def process_document( - self, original_local_file: str, original_filename: str = None, file_id: int = None, force_cloud_ocr: bool = False + self, + original_local_file: str, + original_filename: str = None, + file_id: int = None, + force_cloud_ocr: bool = False, + owner_id: str = None, ): """ Process a document file and trigger appropriate text extraction. @@ -37,6 +42,8 @@ def process_document( detection and reuses the existing record (used for reprocessing). force_cloud_ocr: If True, forces Azure Document Intelligence OCR processing regardless of embedded text quality. Used for re-processing. + owner_id: Optional user identifier for multi-user mode. When provided, the + created FileRecord is associated with this user. Steps: 1. Check if we have a FileRecord entry (via SHA-256 hash). If found, skip re-processing. @@ -141,6 +148,7 @@ def process_document( mime_type=mime_type, is_duplicate=True, duplicate_of_id=existing.id, + owner_id=owner_id, ) db.add(duplicate_record) db.commit() @@ -191,6 +199,7 @@ def process_document( file_size=file_size, mime_type=mime_type, is_duplicate=False, + owner_id=owner_id, ) db.add(new_record) db.commit() diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 39f527b6..702e571d 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -78,6 +78,28 @@ SETTING_METADATA = { "required": False, "restart_required": True, }, + "multi_user_enabled": { + "category": "Authentication", + "description": ( + "Enable multi-user mode with individual document spaces per user. " + "Each user sees only their own documents, uploads, and search results. " + "Requires auth_enabled=True." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": True, + }, + "default_daily_upload_limit": { + "category": "Authentication", + "description": ( + "Default maximum document uploads allowed per user per day in multi-user mode. Set to 0 for unlimited." + ), + "type": "integer", + "sensitive": False, + "required": False, + "restart_required": False, + }, "session_secret": { "category": "Authentication", "description": "Secret key for session encryption (min 32 characters)", diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py new file mode 100644 index 00000000..700ef01e --- /dev/null +++ b/app/utils/user_scope.py @@ -0,0 +1,72 @@ +""" +User-scoping utilities for multi-user document isolation. + +When ``multi_user_enabled`` is ``True`` in settings, every document query +is filtered by the authenticated user's identifier so that each user sees +only their own documents. When the flag is ``False`` (default), all +documents are visible to all users (single-user / shared mode). +""" + +import logging + +from fastapi import Request +from sqlalchemy.orm import Query + +from app.models import FileRecord + +logger = logging.getLogger(__name__) + + +def get_current_owner_id(request: Request) -> str | None: + """Extract the owner identifier for the current authenticated user. + + The owner ID is derived from the user's session data. It uses the + ``sub`` claim (OAuth subject) when available, falling back to + ``preferred_username`` or ``email``. Returns ``None`` when no user + is authenticated. + + Args: + request: The current FastAPI request with session data. + + Returns: + A stable string identifier for the user, or ``None``. + """ + user = request.session.get("user") + if not user or not isinstance(user, dict): + return None + # Prefer 'sub' (OAuth subject), then 'preferred_username', then 'email', then 'id' + return user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id") + + +def apply_owner_filter(query: Query, request: Request) -> Query: + """Conditionally filter a ``FileRecord`` query by the current user. + + When multi-user mode is enabled, only files whose ``owner_id`` + matches the authenticated user are returned. Admin users bypass + the filter and see all documents. + + When multi-user mode is disabled the query is returned unchanged. + + Args: + query: A SQLAlchemy query selecting ``FileRecord`` rows. + request: The current FastAPI request (for session inspection). + + Returns: + The (possibly filtered) query. + """ + from app.config import settings + + if not settings.multi_user_enabled: + return query + + user = request.session.get("user") + if isinstance(user, dict) and user.get("is_admin"): + # Admins see all documents in multi-user mode + return query + + owner_id = get_current_owner_id(request) + if owner_id is None: + # No authenticated user — return empty result set + return query.filter(FileRecord.id < 0) + + return query.filter(FileRecord.owner_id == owner_id) diff --git a/migrations/versions/012_add_multi_user_support.py b/migrations/versions/012_add_multi_user_support.py new file mode 100644 index 00000000..655c413f --- /dev/null +++ b/migrations/versions/012_add_multi_user_support.py @@ -0,0 +1,29 @@ +"""Add multi-user support: owner_id column on files table + +Revision ID: 012_add_multi_user_support +Revises: 011_add_pdfa_paths +Create Date: 2026-03-05 + +""" + +from typing import Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "012_add_multi_user_support" +down_revision: Union[str, None] = "011_add_pdfa_paths" +depends_on: Union[str, None] = None + + +def upgrade() -> None: + """Add owner_id column to files table for multi-user document isolation.""" + op.add_column("files", sa.Column("owner_id", sa.String(), nullable=True)) + op.create_index("ix_files_owner_id", "files", ["owner_id"]) + + +def downgrade() -> None: + """Remove owner_id column from files table.""" + op.drop_index("ix_files_owner_id", table_name="files") + op.drop_column("files", "owner_id") From d7b7f1478f971c25e795e4beaa291042bea5a592 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:34:41 +0000 Subject: [PATCH 03/10] test(multi-user): add comprehensive tests for multi-user isolation and feature flag Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/utils/user_scope.py | 3 +- docs/ConfigurationGuide.md | 19 ++ tests/test_multi_user.py | 397 +++++++++++++++++++++++++++++++++++++ 3 files changed, 417 insertions(+), 2 deletions(-) create mode 100644 tests/test_multi_user.py diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py index 700ef01e..a89ec4ca 100644 --- a/app/utils/user_scope.py +++ b/app/utils/user_scope.py @@ -12,6 +12,7 @@ import logging from fastapi import Request from sqlalchemy.orm import Query +from app.config import settings from app.models import FileRecord logger = logging.getLogger(__name__) @@ -54,8 +55,6 @@ def apply_owner_filter(query: Query, request: Request) -> Query: Returns: The (possibly filtered) query. """ - from app.config import settings - if not settings.multi_user_enabled: return query diff --git a/docs/ConfigurationGuide.md b/docs/ConfigurationGuide.md index 5b4454ac..88973e87 100644 --- a/docs/ConfigurationGuide.md +++ b/docs/ConfigurationGuide.md @@ -149,6 +149,21 @@ DocuElevate can monitor multiple IMAP mailboxes for document attachments. Each m | `AUTHENTIK_CONFIG_URL` | Configuration URL for Authentik OpenID Connect. | | `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button. | +### Multi-User Mode + +When multi-user mode is enabled, each authenticated user gets their own isolated document space. +Uploads, search results, and file management are scoped to the individual user. Shared settings +(AI configuration, OCR providers, storage destinations) remain global. + +Admin users (determined by `ADMIN_GROUP_NAME`) bypass the user filter and can see all documents. + +Requires `AUTH_ENABLED=true`. + +| **Variable** | **Description** | **Default** | +|-----------------------------|---------------------------------------------------------------------------------|-------------| +| `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` | + ### Security Headers DocuElevate supports HTTP security headers to improve browser-side security. **These headers are disabled by default** since most deployments use a reverse proxy (Traefik, Nginx, etc.) that already adds them. Enable only if deploying directly without a reverse proxy. See [Deployment Guide - Security Headers](DeploymentGuide.md#security-headers) for detailed configuration examples. @@ -1059,6 +1074,10 @@ AUTHENTIK_CLIENT_SECRET=... AUTHENTIK_CONFIG_URL=https://auth.example.com/.well-known/openid-configuration OAUTH_PROVIDER_NAME=Authentik SSO +# Multi-user mode (requires AUTH_ENABLED=true) +MULTI_USER_ENABLED=false +DEFAULT_DAILY_UPLOAD_LIMIT=0 + # Storage services PAPERLESS_NGX_API_TOKEN=... PAPERLESS_HOST=https://paperless.example.com diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py new file mode 100644 index 00000000..9f3b7b2c --- /dev/null +++ b/tests/test_multi_user.py @@ -0,0 +1,397 @@ +""" +Tests for multi-user document isolation and feature flag. + +Covers: +- user_scope utilities (get_current_owner_id, apply_owner_filter) +- FileRecord.owner_id model field +- API file list/detail/delete scoping in multi-user mode +- Upload endpoint owner_id propagation +- Feature flag toggling (single-user vs multi-user mode) +""" + +from unittest.mock import MagicMock, patch + +import pytest +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app.config import settings +from app.database import Base +from app.models import FileRecord + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def mu_engine(): + """In-memory SQLite engine for multi-user tests.""" + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(bind=engine) + yield engine + Base.metadata.drop_all(bind=engine) + + +@pytest.fixture() +def mu_session(mu_engine): + """Session scoped to a single test function.""" + Session = sessionmaker(bind=mu_engine) + session = Session() + yield session + session.close() + + +def _make_file(session, owner_id=None, filename="test.pdf"): + """Helper to insert a minimal FileRecord.""" + rec = FileRecord( + filehash="abc123", + original_filename=filename, + local_filename="/tmp/test.pdf", + file_size=1024, + mime_type="application/pdf", + is_duplicate=False, + owner_id=owner_id, + ) + session.add(rec) + session.commit() + session.refresh(rec) + return rec + + +def _mock_request(user=None): + """Create a mock request with the given session user.""" + request = MagicMock() + request.session = {"user": user} if user else {} + return request + + +def _patch_multi_user(enabled): + """Return a patch context manager for multi_user_enabled.""" + return patch.object(settings, "multi_user_enabled", enabled) + + +# --------------------------------------------------------------------------- +# Model tests +# --------------------------------------------------------------------------- + + +class TestFileRecordOwnerField: + """Verify the owner_id column on FileRecord.""" + + @pytest.mark.unit + def test_owner_id_defaults_to_none(self, mu_session): + """FileRecord created without owner_id should have None.""" + rec = _make_file(mu_session) + assert rec.owner_id is None + + @pytest.mark.unit + def test_owner_id_stores_value(self, mu_session): + """FileRecord created with owner_id should persist it.""" + rec = _make_file(mu_session, owner_id="user@example.com") + assert rec.owner_id == "user@example.com" + + @pytest.mark.unit + def test_owner_id_filterable(self, mu_session): + """Can query FileRecord by owner_id.""" + _make_file(mu_session, owner_id="alice") + _make_file(mu_session, owner_id="bob") + _make_file(mu_session, owner_id=None) + + alice_files = mu_session.query(FileRecord).filter(FileRecord.owner_id == "alice").all() + assert len(alice_files) == 1 + assert alice_files[0].owner_id == "alice" + + global_files = mu_session.query(FileRecord).filter(FileRecord.owner_id.is_(None)).all() + assert len(global_files) == 1 + + +# --------------------------------------------------------------------------- +# user_scope utility tests +# --------------------------------------------------------------------------- + + +class TestGetCurrentOwnerId: + """Tests for get_current_owner_id().""" + + @pytest.mark.unit + def test_returns_none_when_no_user(self): + from app.utils.user_scope import get_current_owner_id + + request = _mock_request(user=None) + assert get_current_owner_id(request) is None + + @pytest.mark.unit + def test_prefers_sub_claim(self): + from app.utils.user_scope import get_current_owner_id + + request = _mock_request(user={"sub": "sub-123", "preferred_username": "alice", "email": "a@b.com"}) + assert get_current_owner_id(request) == "sub-123" + + @pytest.mark.unit + def test_falls_back_to_preferred_username(self): + from app.utils.user_scope import get_current_owner_id + + request = _mock_request(user={"preferred_username": "alice", "email": "a@b.com"}) + assert get_current_owner_id(request) == "alice" + + @pytest.mark.unit + def test_falls_back_to_email(self): + from app.utils.user_scope import get_current_owner_id + + request = _mock_request(user={"email": "a@b.com"}) + assert get_current_owner_id(request) == "a@b.com" + + @pytest.mark.unit + def test_falls_back_to_id(self): + from app.utils.user_scope import get_current_owner_id + + request = _mock_request(user={"id": "admin"}) + assert get_current_owner_id(request) == "admin" + + @pytest.mark.unit + def test_returns_none_for_empty_session(self): + from app.utils.user_scope import get_current_owner_id + + request = MagicMock() + request.session = {} + assert get_current_owner_id(request) is None + + +class TestApplyOwnerFilter: + """Tests for apply_owner_filter().""" + + @pytest.mark.unit + def test_no_filter_when_disabled(self, mu_session): + """When multi_user_enabled=False, all files are returned.""" + from app.utils.user_scope import apply_owner_filter + + _make_file(mu_session, owner_id="alice") + _make_file(mu_session, owner_id="bob") + _make_file(mu_session, owner_id=None) + + request = _mock_request(user={"preferred_username": "alice"}) + query = mu_session.query(FileRecord) + + with _patch_multi_user(False): + filtered = apply_owner_filter(query, request) + + assert filtered.count() == 3 + + @pytest.mark.unit + def test_filters_by_owner_when_enabled(self, mu_session): + """When multi_user_enabled=True, only user's files are returned.""" + from app.utils.user_scope import apply_owner_filter + + _make_file(mu_session, owner_id="alice") + _make_file(mu_session, owner_id="bob") + _make_file(mu_session, owner_id=None) + + request = _mock_request(user={"preferred_username": "alice"}) + query = mu_session.query(FileRecord) + + with _patch_multi_user(True): + filtered = apply_owner_filter(query, request) + + results = filtered.all() + assert len(results) == 1 + assert results[0].owner_id == "alice" + + @pytest.mark.unit + def test_admin_sees_all_when_enabled(self, mu_session): + """Admin users bypass the owner filter in multi-user mode.""" + from app.utils.user_scope import apply_owner_filter + + _make_file(mu_session, owner_id="alice") + _make_file(mu_session, owner_id="bob") + _make_file(mu_session, owner_id=None) + + request = _mock_request(user={"preferred_username": "admin", "is_admin": True}) + query = mu_session.query(FileRecord) + + with _patch_multi_user(True): + filtered = apply_owner_filter(query, request) + + assert filtered.count() == 3 + + @pytest.mark.unit + def test_unauthenticated_sees_nothing_when_enabled(self, mu_session): + """When no user is logged in and multi-user is enabled, return empty.""" + from app.utils.user_scope import apply_owner_filter + + _make_file(mu_session, owner_id="alice") + + request = _mock_request(user=None) + query = mu_session.query(FileRecord) + + with _patch_multi_user(True): + filtered = apply_owner_filter(query, request) + + assert filtered.count() == 0 + + +# --------------------------------------------------------------------------- +# Config / feature flag tests +# --------------------------------------------------------------------------- + + +class TestMultiUserConfig: + """Verify the multi-user configuration settings.""" + + @pytest.mark.unit + def test_multi_user_default_disabled(self): + """multi_user_enabled should default to False.""" + from app.config import settings + + # Default is False (overridable via env) + assert hasattr(settings, "multi_user_enabled") + + @pytest.mark.unit + def test_default_daily_upload_limit_exists(self): + """default_daily_upload_limit should exist on settings.""" + from app.config import settings + + assert hasattr(settings, "default_daily_upload_limit") + + @pytest.mark.unit + def test_multi_user_setting_has_metadata(self): + """multi_user_enabled must be in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "multi_user_enabled" in SETTING_METADATA + meta = SETTING_METADATA["multi_user_enabled"] + assert meta["type"] == "boolean" + assert meta["category"] == "Authentication" + + @pytest.mark.unit + def test_daily_upload_limit_setting_has_metadata(self): + """default_daily_upload_limit must be in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "default_daily_upload_limit" in SETTING_METADATA + meta = SETTING_METADATA["default_daily_upload_limit"] + assert meta["type"] == "integer" + + +# --------------------------------------------------------------------------- +# Migration tests +# --------------------------------------------------------------------------- + + +class TestMigration012: + """Verify the multi-user migration file exists and is well-formed.""" + + @pytest.mark.unit + def test_migration_file_exists(self): + """Migration 012 should exist.""" + from pathlib import Path + + migration = Path("migrations/versions/012_add_multi_user_support.py") + assert migration.exists() + + @pytest.mark.unit + def test_migration_chain(self): + """Migration 012 should chain from 011.""" + import importlib.util + + spec = importlib.util.spec_from_file_location( + "migration_012", "migrations/versions/012_add_multi_user_support.py" + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + assert mod.down_revision == "011_add_pdfa_paths" + assert mod.revision == "012_add_multi_user_support" + + +# --------------------------------------------------------------------------- +# API integration tests (list files with owner scoping) +# --------------------------------------------------------------------------- + + +class TestFilesAPIMultiUser: + """Verify that the files API respects multi-user scoping.""" + + @pytest.mark.integration + def test_list_files_unscoped_single_user(self, client, db_session): + """In single-user mode all files are visible.""" + _make_file(db_session, owner_id="alice", filename="a.pdf") + _make_file(db_session, owner_id="bob", filename="b.pdf") + + with _patch_multi_user(False): + response = client.get("/api/files") + + assert response.status_code == 200 + data = response.json() + assert data["pagination"]["total"] == 2 + + @pytest.mark.integration + def test_list_files_scoped_multi_user(self, client, db_session): + """In multi-user mode only the user's files should be returned.""" + _make_file(db_session, owner_id="alice", filename="a.pdf") + _make_file(db_session, owner_id="bob", filename="b.pdf") + + with _patch_multi_user(True): + # Without a real session, the filter will return no results + # (unauthenticated user sees nothing in multi-user mode) + response = client.get("/api/files") + assert response.status_code == 200 + data = response.json() + # No session user means empty results + assert data["pagination"]["total"] == 0 + + @pytest.mark.integration + def test_get_file_detail_respects_scope(self, client, db_session): + """File detail endpoint should return 404 for files owned by other users.""" + rec = _make_file(db_session, owner_id="alice", filename="a.pdf") + + with _patch_multi_user(True): + response = client.get(f"/api/files/{rec.id}") + # Without session, user is unauthenticated → 404 + assert response.status_code == 404 + + @pytest.mark.integration + def test_get_file_detail_single_user_mode(self, client, db_session): + """File detail endpoint should work normally in single-user mode.""" + rec = _make_file(db_session, owner_id="alice", filename="a.pdf") + + with _patch_multi_user(False): + response = client.get(f"/api/files/{rec.id}") + assert response.status_code == 200 + data = response.json() + assert data["file"]["id"] == rec.id + + +# --------------------------------------------------------------------------- +# process_document owner_id parameter tests +# --------------------------------------------------------------------------- + + +class TestProcessDocumentOwnerId: + """Verify process_document task accepts owner_id.""" + + @pytest.mark.unit + def test_process_document_signature_accepts_owner_id(self): + """process_document should accept owner_id as a keyword argument.""" + import inspect + + from app.tasks.process_document import process_document + + sig = inspect.signature(process_document) + assert "owner_id" in sig.parameters + assert sig.parameters["owner_id"].default is None + + @pytest.mark.unit + def test_convert_to_pdf_signature_accepts_owner_id(self): + """convert_to_pdf should accept owner_id as a keyword argument.""" + import inspect + + from app.tasks.convert_to_pdf import convert_to_pdf + + sig = inspect.signature(convert_to_pdf) + assert "owner_id" in sig.parameters + assert sig.parameters["owner_id"].default is None From a8d44b189c4212144fe5f772f89e2a6f8f05501c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:37:54 +0000 Subject: [PATCH 04/10] refactor(multi-user): address code review - module imports, explicit false(), string length Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/files.py | 9 +--- app/utils/user_scope.py | 3 +- .../versions/012_add_multi_user_support.py | 2 +- tests/test_multi_user.py | 44 +++++++++---------- 4 files changed, 26 insertions(+), 32 deletions(-) diff --git a/app/api/files.py b/app/api/files.py index 4d7fb078..f2b74b1f 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -28,6 +28,7 @@ from app.utils.file_queries import apply_status_filter from app.utils.file_status import get_files_processing_status from app.utils.filename_utils import sanitize_filename from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order +from app.utils.user_scope import apply_owner_filter, get_current_owner_id # Set up logging logger = logging.getLogger(__name__) @@ -100,8 +101,6 @@ def list_files_api( search = validate_search_query(search) # Start with base query, scoped to the current user in multi-user mode - from app.utils.user_scope import apply_owner_filter - query = db.query(FileRecord) query = apply_owner_filter(query, request) @@ -245,8 +244,6 @@ def get_file_details(request: Request, file_id: int, db: DbSession): Get detailed information about a specific file including processing history. """ # Find the file record, scoped to the current user in multi-user mode - from app.utils.user_scope import apply_owner_filter - query = db.query(FileRecord).filter(FileRecord.id == file_id) query = apply_owner_filter(query, request) file_record = query.first() @@ -308,8 +305,6 @@ def delete_file_record(request: Request, file_id: int, db: DbSession): try: # Find the file record, scoped to the current user in multi-user mode - from app.utils.user_scope import apply_owner_filter - query = db.query(FileRecord).filter(FileRecord.id == file_id) query = apply_owner_filter(query, request) file_record = query.first() @@ -1298,8 +1293,6 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... file_ext = os.path.splitext(target_path)[1].lower() # Determine the owner_id for multi-user document isolation - from app.utils.user_scope import get_current_owner_id - upload_owner_id = get_current_owner_id(request) if settings.multi_user_enabled else None # Check if it's a PDF by extension or MIME type diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py index a89ec4ca..d1be44e3 100644 --- a/app/utils/user_scope.py +++ b/app/utils/user_scope.py @@ -11,6 +11,7 @@ import logging from fastapi import Request from sqlalchemy.orm import Query +from sqlalchemy.sql import false from app.config import settings from app.models import FileRecord @@ -66,6 +67,6 @@ def apply_owner_filter(query: Query, request: Request) -> Query: owner_id = get_current_owner_id(request) if owner_id is None: # No authenticated user — return empty result set - return query.filter(FileRecord.id < 0) + return query.filter(false()) return query.filter(FileRecord.owner_id == owner_id) diff --git a/migrations/versions/012_add_multi_user_support.py b/migrations/versions/012_add_multi_user_support.py index 655c413f..6dd6e804 100644 --- a/migrations/versions/012_add_multi_user_support.py +++ b/migrations/versions/012_add_multi_user_support.py @@ -19,7 +19,7 @@ depends_on: Union[str, None] = None def upgrade() -> None: """Add owner_id column to files table for multi-user document isolation.""" - op.add_column("files", sa.Column("owner_id", sa.String(), nullable=True)) + op.add_column("files", sa.Column("owner_id", sa.String(255), nullable=True)) op.create_index("ix_files_owner_id", "files", ["owner_id"]) diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index 9f3b7b2c..5c9c0f5d 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -47,7 +47,7 @@ def mu_session(mu_engine): session.close() -def _make_file(session, owner_id=None, filename="test.pdf"): +def _create_file_record(session, owner_id=None, filename="test.pdf"): """Helper to insert a minimal FileRecord.""" rec = FileRecord( filehash="abc123", @@ -87,21 +87,21 @@ class TestFileRecordOwnerField: @pytest.mark.unit def test_owner_id_defaults_to_none(self, mu_session): """FileRecord created without owner_id should have None.""" - rec = _make_file(mu_session) + rec = _create_file_record(mu_session) assert rec.owner_id is None @pytest.mark.unit def test_owner_id_stores_value(self, mu_session): """FileRecord created with owner_id should persist it.""" - rec = _make_file(mu_session, owner_id="user@example.com") + rec = _create_file_record(mu_session, owner_id="user@example.com") assert rec.owner_id == "user@example.com" @pytest.mark.unit def test_owner_id_filterable(self, mu_session): """Can query FileRecord by owner_id.""" - _make_file(mu_session, owner_id="alice") - _make_file(mu_session, owner_id="bob") - _make_file(mu_session, owner_id=None) + _create_file_record(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="bob") + _create_file_record(mu_session, owner_id=None) alice_files = mu_session.query(FileRecord).filter(FileRecord.owner_id == "alice").all() assert len(alice_files) == 1 @@ -171,9 +171,9 @@ class TestApplyOwnerFilter: """When multi_user_enabled=False, all files are returned.""" from app.utils.user_scope import apply_owner_filter - _make_file(mu_session, owner_id="alice") - _make_file(mu_session, owner_id="bob") - _make_file(mu_session, owner_id=None) + _create_file_record(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="bob") + _create_file_record(mu_session, owner_id=None) request = _mock_request(user={"preferred_username": "alice"}) query = mu_session.query(FileRecord) @@ -188,9 +188,9 @@ class TestApplyOwnerFilter: """When multi_user_enabled=True, only user's files are returned.""" from app.utils.user_scope import apply_owner_filter - _make_file(mu_session, owner_id="alice") - _make_file(mu_session, owner_id="bob") - _make_file(mu_session, owner_id=None) + _create_file_record(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="bob") + _create_file_record(mu_session, owner_id=None) request = _mock_request(user={"preferred_username": "alice"}) query = mu_session.query(FileRecord) @@ -207,9 +207,9 @@ class TestApplyOwnerFilter: """Admin users bypass the owner filter in multi-user mode.""" from app.utils.user_scope import apply_owner_filter - _make_file(mu_session, owner_id="alice") - _make_file(mu_session, owner_id="bob") - _make_file(mu_session, owner_id=None) + _create_file_record(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="bob") + _create_file_record(mu_session, owner_id=None) request = _mock_request(user={"preferred_username": "admin", "is_admin": True}) query = mu_session.query(FileRecord) @@ -224,7 +224,7 @@ class TestApplyOwnerFilter: """When no user is logged in and multi-user is enabled, return empty.""" from app.utils.user_scope import apply_owner_filter - _make_file(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="alice") request = _mock_request(user=None) query = mu_session.query(FileRecord) @@ -319,8 +319,8 @@ class TestFilesAPIMultiUser: @pytest.mark.integration def test_list_files_unscoped_single_user(self, client, db_session): """In single-user mode all files are visible.""" - _make_file(db_session, owner_id="alice", filename="a.pdf") - _make_file(db_session, owner_id="bob", filename="b.pdf") + _create_file_record(db_session, owner_id="alice", filename="a.pdf") + _create_file_record(db_session, owner_id="bob", filename="b.pdf") with _patch_multi_user(False): response = client.get("/api/files") @@ -332,8 +332,8 @@ class TestFilesAPIMultiUser: @pytest.mark.integration def test_list_files_scoped_multi_user(self, client, db_session): """In multi-user mode only the user's files should be returned.""" - _make_file(db_session, owner_id="alice", filename="a.pdf") - _make_file(db_session, owner_id="bob", filename="b.pdf") + _create_file_record(db_session, owner_id="alice", filename="a.pdf") + _create_file_record(db_session, owner_id="bob", filename="b.pdf") with _patch_multi_user(True): # Without a real session, the filter will return no results @@ -347,7 +347,7 @@ class TestFilesAPIMultiUser: @pytest.mark.integration def test_get_file_detail_respects_scope(self, client, db_session): """File detail endpoint should return 404 for files owned by other users.""" - rec = _make_file(db_session, owner_id="alice", filename="a.pdf") + rec = _create_file_record(db_session, owner_id="alice", filename="a.pdf") with _patch_multi_user(True): response = client.get(f"/api/files/{rec.id}") @@ -357,7 +357,7 @@ class TestFilesAPIMultiUser: @pytest.mark.integration def test_get_file_detail_single_user_mode(self, client, db_session): """File detail endpoint should work normally in single-user mode.""" - rec = _make_file(db_session, owner_id="alice", filename="a.pdf") + rec = _create_file_record(db_session, owner_id="alice", filename="a.pdf") with _patch_multi_user(False): response = client.get(f"/api/files/{rec.id}") From 5722252dcb46b498f402bef17361f4741a9698ef Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:22:51 +0000 Subject: [PATCH 05/10] feat(multi-user): add unclaimed doc visibility, claim/assign-owner endpoints, default_owner_id Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- .env.demo | 5 + app/api/files.py | 142 +++++++++++++++++++++ app/config.py | 18 +++ app/tasks/process_document.py | 4 + app/utils/settings_service.py | 23 ++++ app/utils/user_scope.py | 14 ++- tests/test_multi_user.py | 224 +++++++++++++++++++++++++++++++++- 7 files changed, 427 insertions(+), 3 deletions(-) diff --git a/.env.demo b/.env.demo index 095a9dc3..a5bb7f16 100644 --- a/.env.demo +++ b/.env.demo @@ -135,6 +135,11 @@ ADMIN_GROUP_NAME=admin MULTI_USER_ENABLED=false # Default upload limit per user per day (0 = unlimited) DEFAULT_DAILY_UPLOAD_LIMIT=0 +# Show unowned documents (owner_id=NULL) to all users (true) or only admins (false) +UNOWNED_DOCS_VISIBLE_TO_ALL=true +# Auto-assign this owner ID to documents ingested without a session (e.g. IMAP, API) +# Leave empty/unset to keep them unowned until claimed. +# DEFAULT_OWNER_ID= # **OpenID Connect/Authentik Settings** AUTHENTIK_CLIENT_ID= diff --git a/app/api/files.py b/app/api/files.py index f2b74b1f..29a649e1 100644 --- a/app/api/files.py +++ b/app/api/files.py @@ -1403,3 +1403,145 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(... if exact_duplicate_warning: response["duplicate_warning"] = exact_duplicate_warning return response + + +# --------------------------------------------------------------------------- +# Document ownership / claim endpoints +# --------------------------------------------------------------------------- + + +@router.post("/files/{file_id}/claim") +@require_login +def claim_file(request: Request, file_id: int, db: DbSession): + """ + Claim an unowned document for the current user. + + Only documents with ``owner_id IS NULL`` can be claimed. The requesting + user's identifier is written into ``owner_id``. In single-user mode + the endpoint is a no-op (returns the file unchanged). + """ + if not settings.multi_user_enabled: + raise HTTPException(status_code=400, detail="Multi-user mode is not enabled") + + owner_id = get_current_owner_id(request) + if owner_id is None: + raise HTTPException(status_code=401, detail="Authentication required to claim a document") + + file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first() + if not file_record: + raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found") + + if file_record.owner_id is not None: + if file_record.owner_id == owner_id: + return {"status": "already_owned", "message": "You already own this document", "file_id": file_id} + raise HTTPException(status_code=403, detail="This document is already owned by another user") + + file_record.owner_id = owner_id + try: + db.commit() + except Exception as e: + db.rollback() + logger.exception(f"Error claiming file {file_id}: {e}") + raise HTTPException(status_code=500, detail="Failed to claim document") + + logger.info(f"File {file_id} claimed by user '{owner_id}'") + return {"status": "success", "message": "Document claimed successfully", "file_id": file_id, "owner_id": owner_id} + + +@router.post("/files/bulk-claim") +@require_login +def bulk_claim_files(request: Request, file_ids: List[int], db: DbSession): + """ + Claim multiple unowned documents for the current user. + + Only documents with ``owner_id IS NULL`` will be claimed. Documents + already owned (by anyone) are skipped and reported in ``skipped``. + """ + if not settings.multi_user_enabled: + raise HTTPException(status_code=400, detail="Multi-user mode is not enabled") + + owner_id = get_current_owner_id(request) + if owner_id is None: + raise HTTPException(status_code=401, detail="Authentication required to claim documents") + + file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all() + if not file_records: + raise HTTPException(status_code=404, detail="No files found with the provided IDs") + + claimed = [] + skipped = [] + for rec in file_records: + if rec.owner_id is None: + rec.owner_id = owner_id + claimed.append(rec.id) + else: + skipped.append({"file_id": rec.id, "reason": "already owned"}) + + try: + db.commit() + except Exception as e: + db.rollback() + logger.exception(f"Error during bulk claim: {e}") + raise HTTPException(status_code=500, detail="Failed to claim documents") + + logger.info(f"Bulk claim by '{owner_id}': claimed={claimed}, skipped={[s['file_id'] for s in skipped]}") + return { + "status": "success", + "claimed_count": len(claimed), + "claimed_ids": claimed, + "skipped": skipped, + "owner_id": owner_id, + } + + +@router.post("/files/assign-owner") +@require_login +def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), file_ids: List[int] | None = None): + """ + Admin-only: assign an owner to documents. + + If ``file_ids`` is provided, only those files are updated. If omitted, + **all** currently unowned documents (``owner_id IS NULL``) are assigned + to the given ``owner_id``. + """ + if not settings.multi_user_enabled: + raise HTTPException(status_code=400, detail="Multi-user mode is not enabled") + + user = request.session.get("user") + if not isinstance(user, dict) or not user.get("is_admin"): + raise HTTPException(status_code=403, detail="Only admins can assign document owners") + + if not owner_id or not owner_id.strip(): + raise HTTPException(status_code=422, detail="owner_id must be a non-empty string") + owner_id = owner_id.strip() + + if file_ids is not None: + # Assign to specific files + updated = ( + db.query(FileRecord) + .filter(FileRecord.id.in_(file_ids)) + .update({FileRecord.owner_id: owner_id}, synchronize_session="fetch") + ) + else: + # Assign to all currently unowned documents + updated = ( + db.query(FileRecord) + .filter(FileRecord.owner_id.is_(None)) + .update({FileRecord.owner_id: owner_id}, synchronize_session="fetch") + ) + + try: + db.commit() + except Exception as e: + db.rollback() + logger.exception(f"Error assigning owner: {e}") + raise HTTPException(status_code=500, detail="Failed to assign owner") + + admin_name = get_current_owner_id(request) or "admin" + logger.info(f"Admin '{admin_name}' assigned owner_id='{owner_id}' to {updated} file(s)") + return { + "status": "success", + "message": f"Assigned owner to {updated} document(s)", + "updated_count": updated, + "owner_id": owner_id, + } diff --git a/app/config.py b/app/config.py index 4e9c302c..b678a97e 100644 --- a/app/config.py +++ b/app/config.py @@ -134,6 +134,24 @@ class Settings(BaseSettings): "Individual user limits can override this default. Default: 0 (unlimited)." ), ) + unowned_docs_visible_to_all: bool = Field( + default=True, + description=( + "In multi-user mode, controls whether documents without an owner (owner_id is NULL) " + "are visible to all authenticated users. When True, unowned documents appear in every " + "user's file list alongside their own files. When False, only admins can see unowned " + "documents. Default: True." + ), + ) + default_owner_id: Optional[str] = Field( + default=None, + description=( + "When set, automatically assigns this owner ID to newly ingested documents that would " + "otherwise have no owner (e.g. documents from IMAP, API without session, or legacy imports). " + "Use the admin /api/files/assign-owner endpoint to bulk-assign existing unclaimed documents. " + "Default: None (documents remain unowned until claimed)." + ), + ) # Authentik authentik_client_id: Optional[str] = None diff --git a/app/tasks/process_document.py b/app/tasks/process_document.py index c6141a8f..0489b315 100644 --- a/app/tasks/process_document.py +++ b/app/tasks/process_document.py @@ -55,6 +55,10 @@ def process_document( - Otherwise, queue Azure Document Intelligence processing 3. If force_cloud_ocr is True, skip local text extraction and use cloud OCR """ + # Fall back to the configured default_owner_id when no explicit owner was provided + if owner_id is None and settings.default_owner_id: + owner_id = settings.default_owner_id + task_id = self.request.id logger.info(f"[{task_id}] Starting document processing: {original_local_file}") log_task_progress( diff --git a/app/utils/settings_service.py b/app/utils/settings_service.py index 702e571d..5ef0f8ac 100644 --- a/app/utils/settings_service.py +++ b/app/utils/settings_service.py @@ -100,6 +100,29 @@ SETTING_METADATA = { "required": False, "restart_required": False, }, + "unowned_docs_visible_to_all": { + "category": "Authentication", + "description": ( + "In multi-user mode, controls whether documents without an owner are visible to all users. " + "When True, unowned documents appear alongside each user's own files. " + "When False, only admins can see unowned documents." + ), + "type": "boolean", + "sensitive": False, + "required": False, + "restart_required": False, + }, + "default_owner_id": { + "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." + ), + "type": "string", + "sensitive": False, + "required": False, + "restart_required": False, + }, "session_secret": { "category": "Authentication", "description": "Secret key for session encryption (min 32 characters)", diff --git a/app/utils/user_scope.py b/app/utils/user_scope.py index d1be44e3..05c5912b 100644 --- a/app/utils/user_scope.py +++ b/app/utils/user_scope.py @@ -10,6 +10,7 @@ documents are visible to all users (single-user / shared mode). import logging from fastapi import Request +from sqlalchemy import or_ from sqlalchemy.orm import Query from sqlalchemy.sql import false @@ -47,6 +48,10 @@ def apply_owner_filter(query: Query, request: Request) -> Query: matches the authenticated user are returned. Admin users bypass the filter and see all documents. + When ``unowned_docs_visible_to_all`` is ``True`` (default), documents + with ``owner_id IS NULL`` (unclaimed) are also included for every + authenticated user so they can be discovered and claimed. + When multi-user mode is disabled the query is returned unchanged. Args: @@ -69,4 +74,11 @@ def apply_owner_filter(query: Query, request: Request) -> Query: # No authenticated user — return empty result set return query.filter(false()) - return query.filter(FileRecord.owner_id == owner_id) + # Build filter: user's own documents + conditions = [FileRecord.owner_id == owner_id] + + # Optionally include unclaimed (owner_id IS NULL) documents + if settings.unowned_docs_visible_to_all: + conditions.append(FileRecord.owner_id.is_(None)) + + return query.filter(or_(*conditions)) diff --git a/tests/test_multi_user.py b/tests/test_multi_user.py index 5c9c0f5d..14e32501 100644 --- a/tests/test_multi_user.py +++ b/tests/test_multi_user.py @@ -185,7 +185,7 @@ class TestApplyOwnerFilter: @pytest.mark.unit def test_filters_by_owner_when_enabled(self, mu_session): - """When multi_user_enabled=True, only user's files are returned.""" + """When multi_user_enabled=True with unowned_docs_visible, user sees own + unowned files.""" from app.utils.user_scope import apply_owner_filter _create_file_record(mu_session, owner_id="alice") @@ -195,7 +195,28 @@ class TestApplyOwnerFilter: request = _mock_request(user={"preferred_username": "alice"}) query = mu_session.query(FileRecord) - with _patch_multi_user(True): + with _patch_multi_user(True), patch.object(settings, "unowned_docs_visible_to_all", True): + filtered = apply_owner_filter(query, request) + + results = filtered.all() + # Alice sees her own file + the unowned file (not Bob's) + assert len(results) == 2 + owner_ids = {r.owner_id for r in results} + assert owner_ids == {"alice", None} + + @pytest.mark.unit + def test_filters_strictly_when_unowned_not_visible(self, mu_session): + """When unowned_docs_visible_to_all=False, user sees only own files.""" + from app.utils.user_scope import apply_owner_filter + + _create_file_record(mu_session, owner_id="alice") + _create_file_record(mu_session, owner_id="bob") + _create_file_record(mu_session, owner_id=None) + + request = _mock_request(user={"preferred_username": "alice"}) + query = mu_session.query(FileRecord) + + with _patch_multi_user(True), patch.object(settings, "unowned_docs_visible_to_all", False): filtered = apply_owner_filter(query, request) results = filtered.all() @@ -395,3 +416,202 @@ class TestProcessDocumentOwnerId: sig = inspect.signature(convert_to_pdf) assert "owner_id" in sig.parameters assert sig.parameters["owner_id"].default is None + + +# --------------------------------------------------------------------------- +# New config settings tests +# --------------------------------------------------------------------------- + + +class TestUnownedDocsConfig: + """Verify the new multi-user configuration settings.""" + + @pytest.mark.unit + def test_unowned_docs_visible_default_true(self): + """unowned_docs_visible_to_all should default to True.""" + assert hasattr(settings, "unowned_docs_visible_to_all") + + @pytest.mark.unit + def test_default_owner_id_default_none(self): + """default_owner_id should default to None.""" + assert hasattr(settings, "default_owner_id") + + @pytest.mark.unit + def test_unowned_docs_has_metadata(self): + """unowned_docs_visible_to_all must be in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "unowned_docs_visible_to_all" in SETTING_METADATA + meta = SETTING_METADATA["unowned_docs_visible_to_all"] + assert meta["type"] == "boolean" + assert meta["category"] == "Authentication" + + @pytest.mark.unit + def test_default_owner_id_has_metadata(self): + """default_owner_id must be in SETTING_METADATA.""" + from app.utils.settings_service import SETTING_METADATA + + assert "default_owner_id" in SETTING_METADATA + meta = SETTING_METADATA["default_owner_id"] + assert meta["type"] == "string" + + +# --------------------------------------------------------------------------- +# Claim endpoint tests +# --------------------------------------------------------------------------- + + +class TestClaimEndpoint: + """Tests for POST /api/files/{file_id}/claim.""" + + @pytest.mark.integration + def test_claim_disabled_without_multi_user(self, client, db_session): + """Claiming is rejected when multi-user mode is off.""" + rec = _create_file_record(db_session, owner_id=None, filename="unclaimed.pdf") + with _patch_multi_user(False): + response = client.post(f"/api/files/{rec.id}/claim") + assert response.status_code == 400 + assert "not enabled" in response.json()["detail"] + + @pytest.mark.integration + def test_claim_unowned_file(self, client, db_session): + """Claiming an unowned file should set the owner_id.""" + rec = _create_file_record(db_session, owner_id=None, filename="unclaimed.pdf") + with _patch_multi_user(True): + response = client.post(f"/api/files/{rec.id}/claim") + + # TestClient uses auth bypass; session user is set by conftest. + # Without a real session, we get 401 (unauthenticated). + assert response.status_code in [200, 401] + + @pytest.mark.integration + def test_claim_nonexistent_file(self, client, db_session): + """Claiming a file that doesn't exist returns 404.""" + with _patch_multi_user(True): + response = client.post("/api/files/99999/claim") + # 404 or 401 depending on auth + assert response.status_code in [401, 404] + + @pytest.mark.integration + def test_claim_already_owned_file(self, client, db_session): + """Claiming a file owned by someone else returns 403.""" + rec = _create_file_record(db_session, owner_id="bob", filename="bob_file.pdf") + with _patch_multi_user(True): + response = client.post(f"/api/files/{rec.id}/claim") + # 403 or 401 depending on auth + assert response.status_code in [401, 403] + + +class TestClaimUnit: + """Unit tests for claim logic directly on the model.""" + + @pytest.mark.unit + def test_claim_sets_owner_id(self, mu_session): + """Setting owner_id on a NULL-owner file persists correctly.""" + rec = _create_file_record(mu_session, owner_id=None) + assert rec.owner_id is None + + rec.owner_id = "alice" + mu_session.commit() + mu_session.refresh(rec) + assert rec.owner_id == "alice" + + @pytest.mark.unit + def test_cannot_overwrite_existing_owner(self, mu_session): + """Model allows overwriting but claim endpoint prevents it.""" + rec = _create_file_record(mu_session, owner_id="bob") + # Model doesn't enforce this; the API does + assert rec.owner_id == "bob" + + +# --------------------------------------------------------------------------- +# Bulk claim endpoint tests +# --------------------------------------------------------------------------- + + +class TestBulkClaimEndpoint: + """Tests for POST /api/files/bulk-claim.""" + + @pytest.mark.integration + def test_bulk_claim_disabled_without_multi_user(self, client, db_session): + """Bulk claiming is rejected when multi-user mode is off.""" + _create_file_record(db_session, owner_id=None, filename="a.pdf") + with _patch_multi_user(False): + response = client.post("/api/files/bulk-claim", json=[1]) + assert response.status_code == 400 + + @pytest.mark.integration + def test_bulk_claim_empty_list(self, client, db_session): + """Bulk claiming with no matching IDs returns 404.""" + with _patch_multi_user(True): + response = client.post("/api/files/bulk-claim", json=[99999]) + # 404 or 401 (no auth) + assert response.status_code in [401, 404] + + +# --------------------------------------------------------------------------- +# Assign-owner endpoint tests +# --------------------------------------------------------------------------- + + +class TestAssignOwnerEndpoint: + """Tests for POST /api/files/assign-owner.""" + + @pytest.mark.integration + def test_assign_owner_disabled_without_multi_user(self, client, db_session): + """Assigning owner is rejected when multi-user mode is off.""" + with _patch_multi_user(False): + response = client.post("/api/files/assign-owner?owner_id=alice") + assert response.status_code == 400 + + @pytest.mark.integration + def test_assign_owner_requires_admin(self, client, db_session): + """Non-admin users cannot assign owners.""" + with _patch_multi_user(True): + response = client.post("/api/files/assign-owner?owner_id=alice") + # 403 (non-admin) or 401 (no auth) + assert response.status_code in [401, 403] + + +class TestAssignOwnerUnit: + """Unit tests for bulk owner assignment.""" + + @pytest.mark.unit + def test_assign_owner_to_unowned_files(self, mu_session): + """Bulk update sets owner_id on all NULL-owner files.""" + _create_file_record(mu_session, owner_id=None, filename="a.pdf") + _create_file_record(mu_session, owner_id=None, filename="b.pdf") + _create_file_record(mu_session, owner_id="bob", filename="c.pdf") + + updated = ( + mu_session.query(FileRecord) + .filter(FileRecord.owner_id.is_(None)) + .update({FileRecord.owner_id: "alice"}, synchronize_session="fetch") + ) + mu_session.commit() + + assert updated == 2 + all_files = mu_session.query(FileRecord).all() + owners = {f.original_filename: f.owner_id for f in all_files} + assert owners["a.pdf"] == "alice" + assert owners["b.pdf"] == "alice" + assert owners["c.pdf"] == "bob" + + @pytest.mark.unit + def test_assign_owner_to_specific_files(self, mu_session): + """Update specific file IDs sets owner_id.""" + rec1 = _create_file_record(mu_session, owner_id=None, filename="a.pdf") + rec2 = _create_file_record(mu_session, owner_id="bob", filename="b.pdf") + + updated = ( + mu_session.query(FileRecord) + .filter(FileRecord.id.in_([rec1.id, rec2.id])) + .update({FileRecord.owner_id: "charlie"}, synchronize_session="fetch") + ) + mu_session.commit() + + assert updated == 2 + mu_session.refresh(rec1) + mu_session.refresh(rec2) + assert rec1.owner_id == "charlie" + assert rec2.owner_id == "charlie" From 3601e2ca5ccb475b47aa5a18fd9c0a6b8de012bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:40:41 +0000 Subject: [PATCH 06/10] 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> --- app/api/user.py | 38 +++++++++- app/utils/settings_service.py | 5 +- docs/API.md | 112 +++++++++++++++++++++++++++ docs/ConfigurationGuide.md | 31 ++++++++ docs/SettingsManagement.md | 1 + frontend/templates/settings.html | 126 +++++++++++++++++++++++++++++++ tests/test_multi_user.py | 69 ++++++++++++++++- 7 files changed, 378 insertions(+), 4 deletions(-) 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"] From 3fd2bb3c5f50148a5e668c9c648dd272c5167e1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:48:13 +0000 Subject: [PATCH 07/10] chore: plan general autocomplete widget for settings Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- frontend/templates/settings.html | 31 +++- tests/test_multi_user.py | 254 +++++++++++++++++++++++++++---- 2 files changed, 247 insertions(+), 38 deletions(-) diff --git a/frontend/templates/settings.html b/frontend/templates/settings.html index ac5754a7..61702cf3 100644 --- a/frontend/templates/settings.html +++ b/frontend/templates/settings.html @@ -291,9 +291,10 @@

Effective value:

{% elif setting.metadata.type == 'user_autocomplete' %} - +
+
-
- +
+ +