diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b051d1fb1..c127a3b0c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: test_models_disabled: name: Pytest (Models Disabled) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 strategy: matrix: @@ -116,7 +116,7 @@ jobs: test_models_enabled: name: Pytest (Models Enabled) runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 strategy: matrix: @@ -144,6 +144,7 @@ jobs: run: uv run --all-extras pytest tests/end_to_end build: + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository runs-on: ubuntu-latest steps: - name: Checkout repo diff --git a/.gitignore b/.gitignore index 4fcc5444d..65cc885d3 100644 --- a/.gitignore +++ b/.gitignore @@ -177,6 +177,7 @@ package.json data/jet_images/ toktagger/ui/.vite/ +*.tsbuildinfo -# Ignore config file -toktagger.toml \ No newline at end of file +# Local Mongita database directory (should live in user cache dir, not the repo) +toktagger_db/ diff --git a/README.md b/README.md index fa6b944bd..3db0ca263 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ It currently supports the following features: - **Annotation Tools**: Apply consistent labels to signals and images using a customizable tagging system. - **ML Models**: Train and infer from ML models within the UI. - **Dataset Management**: Organize and manage annotations in a central repository. +- **Multi-User Support**: Role-based access control with per-project membership, suitable for team annotation workflows. - **Extensible API**: A Python API for integrating with existing workflows and tools. @@ -56,10 +57,54 @@ uv tool install --python 3.12.6 toktagger[models] ``` ## Quick Start -To get started, run: + +To start the application: ```sh toktagger ``` -This will start a local instance of the application running at `http://localhost:8002`. +This launches 4 Gunicorn workers and opens the UI at `http://localhost:8002`. On first launch an `admin` account is created automatically and the credentials are printed to the terminal. + +### Options + +| Flag | Default | Description | +|------|---------|-------------| +| `--workers N` | `4` | Number of Gunicorn worker processes | +| `--host HOST` | `0.0.0.0` | Host to bind to | +| `--port PORT` | `8002` | Port to listen on | +| `--no-browser` | off | Suppress automatic browser launch | +| `--reload` | off | Auto-reload on code changes (single-worker dev mode only) | + +### Development Mode + +For local development with automatic reload on code changes, use a single worker: + +```sh +toktagger --workers 1 --reload +``` + +### Multi-User / Team Deployment + +For server deployments, run with multiple workers and disable the automatic browser launch: + +```sh +toktagger --workers 4 --host 0.0.0.0 --port 8002 --no-browser +``` + +Or directly via Gunicorn (use `python -m gunicorn` to ensure the correct virtual environment is used): + +```sh +python -m gunicorn toktagger.api.asgi:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 4 \ + --bind 0.0.0.0:8002 +``` + +With Docker Compose, the production stack defaults to 4 workers. Override with the `WORKERS` environment variable: + +```sh +WORKERS=8 docker compose up +``` + +See the [User Management](docs/user_management.md) guide for creating accounts, assigning roles, and managing project membership. diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index a83f33a8f..563ed53d9 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -46,12 +46,15 @@ services: - ${CUSTOM_SCRIPT:-./toktagger/api/run.py}:/app/run.py - ~/.sal/:/root/.sal environment: - DATABASE_MONGO_URL: "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongo:27017" - MODELS_CACHE_DIR: "/app/data/models" - SERVER_HOST: api_app - SERVER_PORT: 8002 - SERVER_RELOAD: "true" - CUSTOM_SCRIPT: ${CUSTOM_SCRIPT} + MONGO_URL: "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongo:27017" + UDA_HOST: "uda2.mast.l" + UDA_META_PLUGINNAME: "MASTU_DB" + UDA_METANEW_PLUGINNAME: "MAST_DB" + SAL_HOST: "https://sal.jetdata.eu" + MODEL_STORAGE: "/app/data/models" + API_URL: "http://api_app:8002" + RELOAD: "true" + WORKERS: "1" working_dir: /app command: ["python", "run.py"] networks: diff --git a/docker-compose.yml b/docker-compose.yml index a2dfde291..31a5f6973 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,12 +46,14 @@ services: - ${CUSTOM_SCRIPT:-./toktagger/api/run.py}:/app/run.py - ~/.sal/:/root/.sal environment: - DATABASE_MONGO_URL: "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongo:27017" - MODELS_CACHE_DIR: "/app/data/models" - SERVER_HOST: api_app - SERVER_PORT: 8002 - SERVER_RELOAD: "false" - CUSTOM_SCRIPT: ${CUSTOM_SCRIPT} + MONGO_URL: "mongodb://${MONGO_USERNAME}:${MONGO_PASSWORD}@mongo:27017" + UDA_HOST: "uda2.mast.l" + UDA_META_PLUGINNAME: "MASTU_DB" + UDA_METANEW_PLUGINNAME: "MAST_DB" + MODEL_STORAGE: "/app/data/models" + SAL_HOST: "https://sal.jetdata.eu" + API_URL: "http://api_app:8002" + WORKERS: "${WORKERS:-4}" working_dir: /app command: ["python", "run.py"] networks: diff --git a/docs/dev/developer-setup.md b/docs/dev/developer-setup.md index af2ea83d7..85afe5aff 100644 --- a/docs/dev/developer-setup.md +++ b/docs/dev/developer-setup.md @@ -21,9 +21,11 @@ npm --prefix toktagger/ui install 5. Run the backend API service in development mode. The backend API will be accessible at `http://localhost:8002`. ```sh -API_URL=http://0.0.0.0:8002 uvicorn toktagger.api.cli:app --host 0.0.0.0 --port 8002 --reload +toktagger --workers 1 --reload --no-browser ``` +This starts a single Uvicorn worker with auto-reload enabled. The `--no-browser` flag suppresses the automatic browser launch since the frontend dev server (step 6) is used instead. + 6. Run the frontend UI service in development mode. The UI will be accessible at `http://localhost:5173` ```sh npm --prefix toktagger/ui run dev diff --git a/docs/index.md b/docs/index.md index 81f15d8ae..d3901195c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -14,6 +14,7 @@ It currently supports the following features: - **Annotation Tools**: Apply consistent labels to signals and images using a customizable tagging system. - **ML Models**: Train and infer from ML models within the UI. - **Dataset Management**: Organize and manage annotations in a central repository. +- **Multi-User Support**: Role-based access control with per-project membership, suitable for team annotation workflows. - **Extensible API**: A Python API for integrating with existing workflows and tools. @@ -49,13 +50,42 @@ uv tool install --python 3.12.6 toktagger[models] ``` ## Quick Start -To get started, run: + +To start a local single-user instance: ```sh toktagger ``` -This will start a local instance of the application running at `http://localhost:8002`. +This starts the application at `http://localhost:8002`. On first launch an `admin` account is created automatically and the credentials are printed to the terminal. + +!!! warning + **Save the generated password immediately** — it is only printed once and cannot be recovered. + +### Multi-User / Team Deployment + +For concurrent multi-user access, run with multiple Gunicorn workers: + +```sh +toktagger --workers 4 --host 0.0.0.0 --port 8002 --no-browser +``` + +Or directly via Gunicorn: + +```sh +gunicorn toktagger.api.asgi:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 4 \ + --bind 0.0.0.0:8002 +``` + +With Docker Compose, the production stack defaults to 4 workers. Override with the `WORKERS` environment variable: + +```sh +WORKERS=8 docker compose up +``` + +See [User Management](user_management.md) for creating accounts, assigning roles, and managing project membership. ## Configuration There are a series of additional options which you can configure to customise the functionality of TokTagger - [find details about these here.](./configuration.md) diff --git a/docs/user_management.md b/docs/user_management.md new file mode 100644 index 000000000..95c71b559 --- /dev/null +++ b/docs/user_management.md @@ -0,0 +1,154 @@ +# User Management + +TokTagger supports multiple concurrent users with role-based access control. An **admin** user manages accounts and project membership; regular **users** annotate within the projects they are assigned to. + +--- + +## User Roles + +TokTagger has two layers of roles: + +### Global roles (account-level) + +| Global Role | Permissions | +|---|---| +| `admin` | Full access: create/edit/delete any project, manage all user accounts, view all annotations | +| `user` | Access only to projects they are a member of | + +### Project roles (per-project membership) + +| Project Role | Permissions | +|---|---| +| `admin` | Manage project membership, delete samples and annotations | +| `annotator` | Submit and update annotations for samples | +| `viewer` | Read-only access to the project's samples and annotations | + +A global `admin` automatically has unrestricted access to all projects regardless of project role. + +--- + +## First-Run Setup + +On first launch TokTagger automatically creates an `admin` account with a random password and prints the credentials to the terminal: + +``` +Admin user created — username: admin password: +``` + +!!! warning + Save this password immediately. You can change it afterwards from the **Profile** page, but it is only printed once. + +--- + +## Signing In + +Navigate to `http://:/ui/login` (or the root URL, which redirects there automatically). Enter your username and password to sign in. + +--- + +## Admin Panel + +The admin panel is accessible from the **Admin Panel** button on the Projects page (visible to admin users only). + +### Viewing Users + +The panel lists all registered accounts with their username, email, role, and active status. + +### Creating a User + +1. Click **Add User**. +2. Fill in **Username**, **Password**, and optionally **Email**. +3. Select a **Role** (`user` or `admin`). +4. Click **Create**. + +### Changing a User's Role + +1. Find the user in the table and click **Edit**. +2. Select the new **Global Role**. +3. Click **Save**. + +!!! note + TokTagger prevents demoting or deactivating the last remaining active admin account to avoid an unrecoverable lockout. + +### Deactivating / Reactivating a User + +Click **Deactivate** (or **Activate**) next to the user. Deactivated accounts cannot sign in but their annotations are preserved. You cannot deactivate your own account. + +### Deleting a User + +Click **Delete** next to the user and confirm. This is permanent. You cannot delete your own account. + +--- + +## Profile Page + +Any signed-in user can update their own profile. Click **Profile** from the Projects page. + +### Updating Email + +Enter a new address in the **Email** field and click **Save Email**. + +### Changing Password + +1. Enter a new password in **New password** (minimum 8 characters). +2. Confirm it in **Confirm new password**. +3. Click **Change Password**. + +--- + +## Project Membership + +Access to a project is controlled per-project. From the project's Samples page, an admin can click **Members** to add or remove users. + +Only members (and admins) can view samples and submit annotations for a given project. + +--- + +## Scripted User & Project Setup + +For automated deployments, the helper script `scripts/setup.py` can create projects and samples via the API using token-based auth: + +```sh +python scripts/setup.py \ + --url http://localhost:8002 \ + --username admin \ + --password +``` + +The script authenticates, obtains a JWT token, and creates projects and sample sets using the REST API. You can adapt it to pre-create user accounts with the `POST /users` endpoint: + +```python +import requests + +token = get_token(base_url, "admin", admin_password) +requests.post( + f"{base_url}/users", + json={"username": "alice", "password": "s3cr3t", "global_role": "user"}, + headers={"Authorization": f"Bearer {token}"}, +) +``` + +--- + +## Multi-User Deployment + +For team use, run the API under **Gunicorn** so multiple requests can be served concurrently: + +```sh +# Command-line (installed package) +toktagger --workers 4 --host 0.0.0.0 --port 8002 + +# Direct Gunicorn invocation +gunicorn toktagger.api.asgi:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 4 \ + --bind 0.0.0.0:8002 +``` + +With Docker Compose the `WORKERS` variable controls the worker count (default 4 in production, 1 in dev): + +```sh +WORKERS=8 docker compose up +``` + +A single Uvicorn worker (the default for `toktagger` without `--workers`) is sufficient for personal/local use but will serialise all requests, so concurrent annotators will experience latency under load. diff --git a/pyproject.toml b/pyproject.toml index ec82c5ad5..144b0ae9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,8 @@ classifiers = [ dependencies = [ "fastapi", - "uvicorn", + "uvicorn[standard]", + "gunicorn", "fsspec", "xarray", "s3fs", @@ -39,6 +40,8 @@ dependencies = [ "bump-my-version>=1.2.7", "platformdirs>=4.4.0", "pydantic-settings>=2.11.0", + "itsdangerous>=2.0.0", + "python-multipart>=0.0.7", ] [project.optional-dependencies] models = [ @@ -47,6 +50,9 @@ models = [ ] +[tool.pytest.ini_options] +asyncio_mode = "auto" + [tool.setuptools.packages.find] where = ["."] include = ["toktagger.api*"] diff --git a/scripts/create_mock_data.py b/scripts/create_mock_data.py index 1333b90b9..b66c5e614 100644 --- a/scripts/create_mock_data.py +++ b/scripts/create_mock_data.py @@ -1,7 +1,9 @@ +import os from pathlib import Path +from argparse import ArgumentParser import numpy import random -from setup import create_project, create_local_samples +from setup import create_project, create_local_samples, get_token import pandas as pd @@ -57,6 +59,27 @@ def create_mock_data(base_path: Path, shot_ids: list): def main(): + parser = ArgumentParser() + parser.add_argument( + "--url", + default=os.environ.get("TOKTAGGER_URL", "http://localhost:8002"), + help="Base URL of the TokTagger API", + ) + parser.add_argument( + "--username", + default=os.environ.get("TOKTAGGER_USERNAME", "admin"), + help="Username for authentication", + ) + parser.add_argument( + "--password", + default=os.environ.get("TOKTAGGER_PASSWORD"), + required=not os.environ.get("TOKTAGGER_PASSWORD"), + help="Password for authentication (or set TOKTAGGER_PASSWORD env var)", + ) + args = parser.parse_args() + + token = get_token(args.url, args.username, args.password) + num_samples = 200 base_path = Path(__file__).parents[1].joinpath("data", "test", "mock_disruptions") base_path.mkdir(parents=True, exist_ok=True) @@ -67,6 +90,8 @@ def main(): "time-series", "tabular", "uncertainty", + token=token, + base_url=args.url, time_max=time[-1], ) # Make annotations to add at same time as sample @@ -85,6 +110,8 @@ def main(): create_local_samples( project_id, list(range(1, num_samples + 1)), + token=token, + base_url=args.url, base_path="data/test/mock_disruptions", file_type="parquet", annotations=annotations, @@ -95,6 +122,8 @@ def main(): create_local_samples( project_id, list(range(num_samples + 1, num_samples + 100)), + token=token, + base_url=args.url, base_path="data/test/mock_disruptions", file_type="parquet", signals=["ip"], diff --git a/scripts/setup.py b/scripts/setup.py index 377fda9ba..c91c54744 100644 --- a/scripts/setup.py +++ b/scripts/setup.py @@ -1,14 +1,34 @@ +import os from argparse import ArgumentParser from pathlib import Path from typing import Optional import requests +BASE_URL = "http://localhost:8002" + + +def get_token(base_url: str, username: str, password: str) -> str: + r = requests.post( + f"{base_url}/auth/token", + data={"username": username, "password": password}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + r.raise_for_status() + return r.json()["access_token"] + + +def _auth(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + def create_project( name: str, task: str, data_loader: str, query_strategy: str, + token: str, + base_url: str = BASE_URL, time_min: float = -0.1, time_max: float = 0.8, min_time_step: float = 0.0001, @@ -24,14 +44,18 @@ def create_project( } response = requests.post( - "http://localhost:8002/projects", + f"{base_url}/projects", json=project, + headers=_auth(token), ) + response.raise_for_status() project_id = response.json()["_id"] return project_id -def create_uda_samples(project_id: str, shot_ids: list[int]): +def create_uda_samples( + project_id: str, shot_ids: list[int], token: str, base_url: str = BASE_URL +): samples = [] for shot_id in shot_ids: sample = { @@ -44,10 +68,17 @@ def create_uda_samples(project_id: str, shot_ids: list[int]): } samples.append(sample) - requests.post(f"http://localhost:8002/projects/{project_id}/samples", json=samples) + r = requests.post( + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), + ) + r.raise_for_status() -def create_sal_samples(project_id: str, shot_ids: list[int]): +def create_sal_samples( + project_id: str, shot_ids: list[int], token: str, base_url: str = BASE_URL +): samples = [] for shot_id in shot_ids: sample = { @@ -60,10 +91,17 @@ def create_sal_samples(project_id: str, shot_ids: list[int]): } samples.append(sample) - requests.post(f"http://localhost:8002/projects/{project_id}/samples", json=samples) + r = requests.post( + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), + ) + r.raise_for_status() -def create_fair_mast_samples(project_id: str, shot_ids: list[int]): +def create_fair_mast_samples( + project_id: str, shot_ids: list[int], token: str, base_url: str = BASE_URL +): samples = [] for shot_id in shot_ids: sample = { @@ -76,14 +114,21 @@ def create_fair_mast_samples(project_id: str, shot_ids: list[int]): } samples.append(sample) - requests.post(f"http://localhost:8002/projects/{project_id}/samples", json=samples) + r = requests.post( + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), + ) + r.raise_for_status() def create_local_samples( project_id: str, shot_ids: list[int], - base_path: str, - file_type: str, + token: str, + base_url: str = BASE_URL, + base_path: str = ".", + file_type: str = "parquet", signals: Optional[list[str]] = None, annotations: Optional[list[dict]] = None, ): @@ -105,10 +150,21 @@ def create_local_samples( sample["annotations"] = annotations[shot_id] samples.append(sample) - requests.post(f"http://localhost:8002/projects/{project_id}/samples", json=samples) + r = requests.post( + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), + ) + r.raise_for_status() -def create_image_samples(project_id: str, shot_ids: list[int], image_dir: str): +def create_image_samples( + project_id: str, + shot_ids: list[int], + image_dir: str, + token: str, + base_url: str = BASE_URL, +): samples = [] for shot_id in shot_ids: samples.append( @@ -125,12 +181,16 @@ def create_image_samples(project_id: str, shot_ids: list[int], image_dir: str): ) r = requests.post( - f"http://localhost:8002/projects/{project_id}/samples", json=samples + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), ) r.raise_for_status() -def create_uda_camera_samples(project_id: str, shot_ids: list[int]): +def create_uda_camera_samples( + project_id: str, shot_ids: list[int], token: str, base_url: str = BASE_URL +): samples = [] for shot_id in shot_ids: sample = { @@ -143,7 +203,12 @@ def create_uda_camera_samples(project_id: str, shot_ids: list[int]): } samples.append(sample) - requests.post(f"http://localhost:8002/projects/{project_id}/samples", json=samples) + r = requests.post( + f"{base_url}/projects/{project_id}/samples", + json=samples, + headers=_auth(token), + ) + r.raise_for_status() def main(): @@ -155,8 +220,26 @@ def main(): type=str, help="Base path for remote data files", ) + parser.add_argument( + "--url", + default=os.environ.get("TOKTAGGER_URL", "http://localhost:8002"), + help="Base URL of the TokTagger API", + ) + parser.add_argument( + "--username", + default=os.environ.get("TOKTAGGER_USERNAME", "admin"), + help="Username for authentication", + ) + parser.add_argument( + "--password", + default=os.environ.get("TOKTAGGER_PASSWORD"), + required=not os.environ.get("TOKTAGGER_PASSWORD"), + help="Password for authentication (or set TOKTAGGER_PASSWORD env var)", + ) args = parser.parse_args() + token = get_token(args.url, args.username, args.password) + base_path = Path(args.base_path) shot_files = Path("./data/test/summary").glob("*.parquet") @@ -164,33 +247,60 @@ def main(): shot_ids = [int(path.stem) for path in shot_files] project_id = create_project( - "UDA Disruption Project", "time-series", "uda", "sequential" + "UDA Disruption Project", + "time-series", + "uda", + "sequential", + token=token, + base_url=args.url, ) - create_uda_samples(project_id, shot_ids) + create_uda_samples(project_id, shot_ids, token=token, base_url=args.url) project_id = create_project( - "Local ELM Project", "time-series", "tabular", "sequential" + "Local ELM Project", + "time-series", + "tabular", + "sequential", + token=token, + base_url=args.url, ) create_local_samples( - project_id, shot_ids, base_path=base_path / "summary", file_type="parquet" + project_id, + shot_ids, + token=token, + base_url=args.url, + base_path=base_path / "summary", + file_type="parquet", ) shot_files = Path("./data/test/mhd").glob("*.parquet") shot_files = list(shot_files) shot_ids = [int(path.stem) for path in shot_files] project_id = create_project( - "Local MHD Project", "spectrogram", "tabular", "random", min_time_step=0.000001 + "Local MHD Project", + "spectrogram", + "tabular", + "random", + token=token, + base_url=args.url, + min_time_step=0.000001, ) create_local_samples( project_id, shot_ids, + token=token, + base_url=args.url, base_path=base_path / "mhd", file_type="parquet", signals=["mirnov"], ) # ---- Image / UFO demo project ---- - project_id = create_project("Frame Project", "video", "image", "random") - create_image_samples(project_id, [10101], Path("./data/test/video/")) + project_id = create_project( + "Frame Project", "video", "image", "random", token=token, base_url=args.url + ) + create_image_samples( + project_id, [10101], Path("./data/test/video/"), token=token, base_url=args.url + ) # JET data project_id = create_project( @@ -198,12 +308,14 @@ def main(): "time-series", "sal", query_strategy="sequential", + token=token, + base_url=args.url, time_min=38, time_max=None, min_time_step=0.0001, ) shot_ids = [87737] - create_sal_samples(project_id, shot_ids) + create_sal_samples(project_id, shot_ids, token=token, base_url=args.url) # FAIR MAST project_id = create_project( @@ -211,15 +323,22 @@ def main(): "time-series", "fair_mast", query_strategy="sequential", + token=token, + base_url=args.url, ) shot_ids = [30421] - create_fair_mast_samples(project_id, shot_ids) + create_fair_mast_samples(project_id, shot_ids, token=token, base_url=args.url) shot_ids = [30421] project_id = create_project( - "UDA Camera Frame Project", "video", "uda_camera", "random" + "UDA Camera Frame Project", + "video", + "uda_camera", + "random", + token=token, + base_url=args.url, ) - create_uda_camera_samples(project_id, shot_ids) + create_uda_camera_samples(project_id, shot_ids, token=token, base_url=args.url) print("Projects and samples created successfully.") diff --git a/tests/api/auth/__init__.py b/tests/api/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/api/auth/conftest.py b/tests/api/auth/conftest.py new file mode 100644 index 000000000..2d6cdce0c --- /dev/null +++ b/tests/api/auth/conftest.py @@ -0,0 +1,113 @@ +"""Conftest for auth tests — uses mongita (no Docker required).""" + +import pytest_asyncio +from httpx import AsyncClient, ASGITransport + +from toktagger.api.main import Server +from toktagger.api.crud.db import MongoDBClient +from toktagger.api.auth.core import hash_password +from toktagger.api.schemas.users import UserIn + + +async def get_auth_token(client: AsyncClient, username: str, password: str) -> str: + """Obtain a JWT access token for the given user.""" + resp = await client.post( + "/auth/token", + data={"username": username, "password": password}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert resp.status_code == 200, f"Login failed ({username}): {resp.text}" + return resp.json()["access_token"] + + +@pytest_asyncio.fixture(scope="function") +async def auth_db_client(tmp_path): + """Low-level DB client backed by mongita (per-test, no Docker).""" + client = MongoDBClient(str(tmp_path), "annotate_db") + yield client + await client.client.close() + + +@pytest_asyncio.fixture(scope="function") +async def auth_api_client(tmp_path): + """Passthrough API client (auth_required=False) — for first_run tests.""" + db = MongoDBClient(str(tmp_path), "annotate_db") + + server = Server() + server._setup_app() + app = server.app + app.state.db_client = db + app.state.auth_required = False + app.state.project = None + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + client.app = app + yield client + + await db.client.close() + + +@pytest_asyncio.fixture(scope="function") +async def auth_setup(tmp_path): + """Auth-aware fixture: auth_required=True with three pre-seeded users. + + Yields a dict with: + - client: AsyncClient for making requests + - admin_id, alice_id, bob_id: inserted user IDs + + Use get_auth_token(client, username, password) to obtain JWT tokens. + """ + db = MongoDBClient(str(tmp_path), "annotate_db") + + server = Server() + server._setup_app() + app = server.app + app.state.db_client = db + app.state.auth_required = True + app.state.project = None + + admin_id = await db.insert( + "users", + UserIn( + username="admin", + hashed_password=hash_password("admin_pass"), + email="admin@test.com", + global_role="admin", + is_active=True, + ), + ) + alice_id = await db.insert( + "users", + UserIn( + username="alice", + hashed_password=hash_password("alice_pass"), + email="alice@test.com", + global_role="user", + is_active=True, + ), + ) + bob_id = await db.insert( + "users", + UserIn( + username="bob", + hashed_password=hash_password("bob_pass"), + email="bob@test.com", + global_role="user", + is_active=True, + ), + ) + + async with AsyncClient( + transport=ASGITransport(app=app), base_url="http://test" + ) as client: + client.app = app + yield { + "client": client, + "admin_id": admin_id, + "alice_id": alice_id, + "bob_id": bob_id, + } + + await db.client.close() diff --git a/tests/api/auth/test_auth_router.py b/tests/api/auth/test_auth_router.py new file mode 100644 index 000000000..4d242df08 --- /dev/null +++ b/tests/api/auth/test_auth_router.py @@ -0,0 +1,108 @@ +"""Integration tests for /auth/token and /auth/me endpoints.""" + +import pytest + +from tests.api.auth.conftest import get_auth_token + + +@pytest.mark.asyncio +async def test_login_success(auth_setup): + client = auth_setup["client"] + response = await client.post( + "/auth/token", + data={"username": "admin", "password": "admin_pass"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 200 + body = response.json() + assert "access_token" in body + assert body["token_type"] == "bearer" + assert len(body["access_token"]) > 0 + + +@pytest.mark.asyncio +async def test_login_wrong_password(auth_setup): + client = auth_setup["client"] + response = await client.post( + "/auth/token", + data={"username": "admin", "password": "wrong_password"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_unknown_user(auth_setup): + client = auth_setup["client"] + response = await client.post( + "/auth/token", + data={"username": "ghost", "password": "doesnt_matter"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_inactive_user(auth_setup): + """Deactivated users cannot log in.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + + # Deactivate alice via the admin API + await client.put( + f"/users/{auth_setup['alice_id']}", + json={"is_active": False}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + response = await client.post( + "/auth/token", + data={"username": "alice", "password": "alice_pass"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_get_me_returns_current_user(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + response = await client.get( + "/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + body = response.json() + assert body["username"] == "alice" + assert body["global_role"] == "user" + assert body["is_active"] is True + assert "hashed_password" not in body + + +@pytest.mark.asyncio +async def test_get_me_admin_role(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "admin", "admin_pass") + response = await client.get( + "/auth/me", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert response.json()["global_role"] == "admin" + + +@pytest.mark.asyncio +async def test_get_me_no_token(auth_setup): + client = auth_setup["client"] + response = await client.get("/auth/me") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_me_invalid_token(auth_setup): + client = auth_setup["client"] + response = await client.get( + "/auth/me", + headers={"Authorization": "Bearer not.a.real.token"}, + ) + assert response.status_code == 401 diff --git a/tests/api/auth/test_concurrent_annotations.py b/tests/api/auth/test_concurrent_annotations.py new file mode 100644 index 000000000..f970e167d --- /dev/null +++ b/tests/api/auth/test_concurrent_annotations.py @@ -0,0 +1,279 @@ +""" +Integration tests for concurrent annotation safety and per-user visibility. + +Key invariants under test: + 1. User A saving annotations does NOT delete User B's annotations. + 2. The server overwrites `created_by` from the JWT — clients cannot spoof identity. + 3. When show_others_annotations=False, a user only sees their own annotations. + 4. Viewer-role users cannot PUT annotations (403). + 5. A project non-member cannot access annotations (403). +""" + +import pytest + +from tests.api.auth.conftest import get_auth_token + + +async def create_project_and_sample(client, token): + """Create a project then add one sample; return (project_id, sample_id).""" + proj_resp = await client.post( + "/projects", + json={ + "name": "concurrency_test", + "task": "time-series", + "query_strategy": "sequential", + "data_loader": "tabular", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert proj_resp.status_code == 200, proj_resp.text + project_id = proj_resp.json()["_id"] + + sample_resp = await client.post( + f"/projects/{project_id}/samples", + json=[ + { + "shot_id": 42, + "data": {"file_name": "test.csv", "type": "csv"}, + } + ], + headers={"Authorization": f"Bearer {token}"}, + ) + assert sample_resp.status_code == 200, sample_resp.text + sample_id = sample_resp.json()[0] + return project_id, sample_id + + +def annotation_payload(label: str): + return [ + { + "label": label, + "time_min": 0.1, + "time_max": 0.5, + "type": "time_region", + "validated": True, + "created_by": "placeholder", # server overwrites from JWT + } + ] + + +async def put_annotations(client, project_id, sample_id, token, label): + resp = await client.put( + f"/projects/{project_id}/samples/{sample_id}/annotations", + json=annotation_payload(label), + headers={"Authorization": f"Bearer {token}"}, + ) + return resp + + +async def get_annotations(client, project_id, sample_id, token): + resp = await client.get( + f"/projects/{project_id}/samples/{sample_id}/annotations", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + return resp.json() + + +@pytest.mark.asyncio +async def test_user_save_does_not_overwrite_other_users_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + for username in ("alice", "bob"): + await client.post( + f"/projects/{project_id}/members", + json={"username": username, "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + bob_token = await get_auth_token(client, "bob", "bob_pass") + + resp_a = await put_annotations( + client, project_id, sample_id, alice_token, "alice_label" + ) + assert resp_a.status_code == 200 + + resp_b = await put_annotations( + client, project_id, sample_id, bob_token, "bob_label" + ) + assert resp_b.status_code == 200 + + annotations = await get_annotations(client, project_id, sample_id, admin_token) + labels = {a["label"] for a in annotations} + assert "alice_label" in labels + assert "bob_label" in labels + + +@pytest.mark.asyncio +async def test_user_save_replaces_only_own_previous_annotations(auth_setup): + """Saving twice as the same user replaces only that user's annotations.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + for username in ("alice", "bob"): + await client.post( + f"/projects/{project_id}/members", + json={"username": username, "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + bob_token = await get_auth_token(client, "bob", "bob_pass") + + await put_annotations(client, project_id, sample_id, alice_token, "alice_v1") + await put_annotations(client, project_id, sample_id, bob_token, "bob_v1") + + await put_annotations(client, project_id, sample_id, alice_token, "alice_v2") + + annotations = await get_annotations(client, project_id, sample_id, admin_token) + labels = {a["label"] for a in annotations} + assert "alice_v2" in labels + assert "alice_v1" not in labels + assert "bob_v1" in labels + + +@pytest.mark.asyncio +async def test_server_overwrites_created_by_from_jwt(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + + spoofed = [ + { + "label": "spoofed", + "time_min": 0.0, + "time_max": 1.0, + "type": "time_region", + "validated": True, + "created_by": "admin", # attempt to impersonate admin + } + ] + resp = await client.put( + f"/projects/{project_id}/samples/{sample_id}/annotations", + json=spoofed, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + annotations = await get_annotations(client, project_id, sample_id, admin_token) + assert len(annotations) == 1 + assert annotations[0]["created_by"] == "alice" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "show_others,expect_bobs_label", [(False, False), (True, True)] +) +async def test_show_others_annotations_filter( + auth_setup, show_others, expect_bobs_label +): + """When show_others_annotations is toggled, alice's view changes accordingly.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + for username in ("alice", "bob"): + await client.post( + f"/projects/{project_id}/members", + json={"username": username, "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + bob_token = await get_auth_token(client, "bob", "bob_pass") + + await put_annotations(client, project_id, sample_id, alice_token, "alice_ann") + await put_annotations(client, project_id, sample_id, bob_token, "bob_ann") + + await client.put( + f"/projects/{project_id}/members/{auth_setup['alice_id']}", + json={"show_others_annotations": show_others}, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + + alice_view = await get_annotations(client, project_id, sample_id, alice_token) + labels = {a["label"] for a in alice_view} + assert "alice_ann" in labels + assert ("bob_ann" in labels) == expect_bobs_label + + +@pytest.mark.asyncio +async def test_viewer_cannot_put_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "viewer"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await put_annotations( + client, project_id, sample_id, alice_token, "viewer_attempt" + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_get_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.get( + f"/projects/{project_id}/samples/{sample_id}/annotations", + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_non_member_cannot_see_project_in_list(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.get( + "/projects", + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 200 + assert resp.json() == [] + + +@pytest.mark.asyncio +async def test_member_can_see_project_in_list(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.get( + "/projects", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + project_ids = [p["_id"] for p in resp.json()] + assert project_id in project_ids diff --git a/tests/api/auth/test_core.py b/tests/api/auth/test_core.py new file mode 100644 index 000000000..47b2ef013 --- /dev/null +++ b/tests/api/auth/test_core.py @@ -0,0 +1,105 @@ +"""Unit tests for toktagger.api.auth.core — no DB or network needed.""" + +import pytest + +from toktagger.api.auth.core import ( + hash_password, + verify_password, + create_access_token, + decode_token, + _get_serializer, +) + + +# --------------------------------------------------------------------------- +# hash_password / verify_password +# --------------------------------------------------------------------------- + + +def test_hash_password_format(): + h = hash_password("secret") + parts = h.split(":") + assert parts[0] == "pbkdf2" + assert len(parts) == 3 + # salt and hash are non-empty hex strings + assert len(parts[1]) > 0 + assert len(parts[2]) > 0 + + +def test_hash_password_produces_unique_salts(): + h1 = hash_password("same") + h2 = hash_password("same") + # Different salts → different stored strings even for identical passwords + assert h1 != h2 + + +def test_verify_password_correct(): + stored = hash_password("correct_password") + assert verify_password("correct_password", stored) is True + + +def test_verify_password_wrong_password(): + stored = hash_password("correct_password") + assert verify_password("wrong_password", stored) is False + + +def test_verify_password_wrong_format_returns_false(): + assert verify_password("anything", "plaintext_hash") is False + + +def test_verify_password_empty_string(): + stored = hash_password("") + assert verify_password("", stored) is True + assert verify_password("notempty", stored) is False + + +# --------------------------------------------------------------------------- +# create_access_token / decode_token (round-trip) +# --------------------------------------------------------------------------- + + +def test_create_access_token_returns_string(): + token = create_access_token({"sub": "alice"}) + assert isinstance(token, str) + assert len(token) > 0 + + +def test_decode_token_round_trip(): + payload = {"sub": "alice", "role": "admin"} + token = create_access_token(payload) + decoded = decode_token(token) + assert decoded["sub"] == "alice" + assert decoded["role"] == "admin" + + +def test_decode_token_expired(monkeypatch): + """Simulate an expired token by monkeypatching the serializer's loads to behave + as if max_age has elapsed. We achieve this by creating a token, then advancing + the timestamp embedded in the signature past the expiry window.""" + from itsdangerous import SignatureExpired + + token = create_access_token({"sub": "expired_user"}) + + serializer = _get_serializer() + + _original_loads = serializer.loads + + def fake_loads(data, **kwargs): + raise SignatureExpired("simulated expiry") + + monkeypatch.setattr(serializer, "loads", fake_loads) + + with pytest.raises(ValueError, match="expired"): + decode_token(token) + + +def test_decode_token_invalid_raises(): + with pytest.raises(ValueError, match="Invalid"): + decode_token("this.is.not.a.valid.token") + + +def test_decode_token_tampered_raises(): + token = create_access_token({"sub": "alice"}) + tampered = token[:-4] + "XXXX" + with pytest.raises(ValueError): + decode_token(tampered) diff --git a/tests/api/auth/test_endpoint_guards.py b/tests/api/auth/test_endpoint_guards.py new file mode 100644 index 000000000..fd772cd18 --- /dev/null +++ b/tests/api/auth/test_endpoint_guards.py @@ -0,0 +1,356 @@ +""" +Integration tests verifying that membership guards are enforced across all resource +endpoints (samples, project-level annotations, sample-level annotation delete, data). + +Permission matrix: + - Non-member → 403 on everything + - Viewer → 200 on reads, 403 on writes/deletes + - Annotator → 200 on reads and writes, 403 on destructive deletes + - Project admin (admin role) → 200 on everything +""" + +import pytest + +from tests.api.auth.conftest import get_auth_token + + +async def create_project_and_sample(client, token): + proj_resp = await client.post( + "/projects", + json={ + "name": "guard_test", + "task": "time-series", + "query_strategy": "sequential", + "data_loader": "tabular", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert proj_resp.status_code == 200, proj_resp.text + project_id = proj_resp.json()["_id"] + + sample_resp = await client.post( + f"/projects/{project_id}/samples", + json=[{"shot_id": 1, "data": {"file_name": "t.csv", "type": "csv"}}], + headers={"Authorization": f"Bearer {token}"}, + ) + assert sample_resp.status_code == 200, sample_resp.text + sample_id = sample_resp.json()[0] + return project_id, sample_id + + +async def add_member(client, token, project_id, username, role): + resp = await client.post( + f"/projects/{project_id}/members", + json={"username": username, "role": role}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + + +def annotation_payload(): + return [ + { + "label": "lbl", + "time_min": 0.0, + "time_max": 1.0, + "type": "time_region", + "validated": False, + "created_by": "placeholder", + } + ] + + +@pytest.mark.asyncio +async def test_non_member_cannot_list_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.get( + f"/projects/{project_id}/samples", + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_viewer_can_list_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "viewer") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.get( + f"/projects/{project_id}/samples", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_non_member_cannot_add_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.post( + f"/projects/{project_id}/samples", + json=[{"shot_id": 99, "data": {"file_name": "x.csv", "type": "csv"}}], + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_viewer_cannot_add_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "viewer") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.post( + f"/projects/{project_id}/samples", + json=[{"shot_id": 99, "data": {"file_name": "x.csv", "type": "csv"}}], + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_annotator_can_add_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.post( + f"/projects/{project_id}/samples", + json=[{"shot_id": 99, "data": {"file_name": "x.csv", "type": "csv"}}], + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_annotator_cannot_delete_sample(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.delete( + f"/projects/{project_id}/samples/{sample_id}", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_project_admin_can_delete_sample(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + # Admin (global) is also an implicit project admin; use them directly. + resp = await client.delete( + f"/projects/{project_id}/samples/{sample_id}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_annotator_cannot_delete_all_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.delete( + f"/projects/{project_id}/samples", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_global_admin_can_delete_all_samples(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + resp = await client.delete( + f"/projects/{project_id}/samples", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_non_member_cannot_get_project_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_viewer_can_get_project_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "viewer") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_viewer_cannot_import_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "viewer") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(), + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_annotator_can_import_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + # Bulk import requires full annotation docs (with sample_id embedded) + payload = [ + { + "label": "lbl", + "time_min": 0.0, + "time_max": 1.0, + "type": "time_region", + "validated": False, + "created_by": "alice", + "shot_id": 1, + "sample_id": sample_id, + "project_id": project_id, + } + ] + resp = await client.put( + f"/projects/{project_id}/annotations", + json=payload, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_annotator_cannot_delete_project_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.delete( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_global_admin_can_delete_project_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + resp = await client.delete( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_annotator_cannot_delete_all_sample_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "annotator") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.delete( + f"/projects/{project_id}/samples/{sample_id}/annotations", + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_global_admin_can_delete_all_sample_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + resp = await client.delete( + f"/projects/{project_id}/samples/{sample_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_non_member_cannot_get_data(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + bob_token = await get_auth_token(client, "bob", "bob_pass") + resp = await client.post( + f"/projects/{project_id}/samples/{sample_id}/data", + json={}, + headers={"Authorization": f"Bearer {bob_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_viewer_passes_data_auth_check(auth_setup): + """Viewer should pass the auth gate (may still get 404/422 for missing data file).""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + await add_member(client, admin_token, project_id, "alice", "viewer") + + alice_token = await get_auth_token(client, "alice", "alice_pass") + resp = await client.post( + f"/projects/{project_id}/samples/{sample_id}/data", + json={}, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code != 403 diff --git a/tests/api/auth/test_first_run.py b/tests/api/auth/test_first_run.py new file mode 100644 index 000000000..a35171f1c --- /dev/null +++ b/tests/api/auth/test_first_run.py @@ -0,0 +1,55 @@ +"""Unit tests for toktagger.api.auth.first_run.""" + +import pytest + +from toktagger.api.auth.first_run import ensure_admin_user + + +@pytest.mark.asyncio +async def test_ensure_admin_user_creates_admin_on_empty_db(auth_db_client): + users_before = await auth_db_client.get_all_documents("users") + assert len(users_before) == 0 + + await ensure_admin_user(auth_db_client) + + users_after = await auth_db_client.get_all_documents("users") + assert len(users_after) == 1 + assert users_after[0]["username"] == "admin" + assert users_after[0]["global_role"] == "admin" + assert users_after[0]["is_active"] is True + + +@pytest.mark.asyncio +async def test_ensure_admin_user_returns_true(auth_db_client): + result = await ensure_admin_user(auth_db_client) + assert result is True + + +@pytest.mark.asyncio +async def test_ensure_admin_user_password_is_hashed(auth_db_client): + await ensure_admin_user(auth_db_client) + users = await auth_db_client.get_all_documents("users") + stored = users[0]["hashed_password"] + # Must be stored in pbkdf2 format, not plain text + assert stored.startswith("pbkdf2:") + + +@pytest.mark.asyncio +async def test_ensure_admin_user_idempotent(auth_db_client): + """Calling twice should not create a second admin.""" + await ensure_admin_user(auth_db_client) + await ensure_admin_user(auth_db_client) + + users = await auth_db_client.get_all_documents("users") + assert len(users) == 1 + + +@pytest.mark.asyncio +async def test_ensure_admin_user_with_existing_users_returns_true(auth_db_client): + """When users already exist, still returns True without creating another.""" + await ensure_admin_user(auth_db_client) + result = await ensure_admin_user(auth_db_client) + assert result is True + + users = await auth_db_client.get_all_documents("users") + assert len(users) == 1 diff --git a/tests/api/auth/test_model_auth.py b/tests/api/auth/test_model_auth.py new file mode 100644 index 000000000..ecb8ca3d2 --- /dev/null +++ b/tests/api/auth/test_model_auth.py @@ -0,0 +1,266 @@ +""" +Tests for model × auth interactions: + 1. Internal API token lets Ray-worker callbacks bypass the annotator guard. + 2. Unauthenticated sender is rejected when auth is required. + 3. Non-admin bulk import enforces created_by = current user. + 4. Global admin bulk import allows arbitrary created_by. + 5. Usernames with reserved prefixes ("model::", "__") are rejected. + 6. A user whose username matches a model-type string cannot corrupt + "model::" prefixed predictions. +""" + +import pytest + +from tests.api.auth.conftest import get_auth_token +from toktagger.api.auth.core import get_internal_token + + +async def create_project_and_sample(client, token): + proj = await client.post( + "/projects", + json={ + "name": "model_auth_test", + "task": "time-series", + "query_strategy": "sequential", + "data_loader": "tabular", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert proj.status_code == 200, proj.text + project_id = proj.json()["_id"] + + sample = await client.post( + f"/projects/{project_id}/samples", + json=[{"shot_id": 1, "data": {"file_name": "t.csv", "type": "csv"}}], + headers={"Authorization": f"Bearer {token}"}, + ) + assert sample.status_code == 200, sample.text + sample_id = sample.json()[0] + return project_id, sample_id + + +def annotation_payload( + label: str = "lbl", created_by: str = "placeholder", shot_id: int = 1 +): + """Payload suitable for bulk import (PUT /projects/{id}/annotations). + shot_id must match an existing sample — the default matches the sample + created by create_project_and_sample (shot_id=1). + """ + return [ + { + "label": label, + "time_min": 0.0, + "time_max": 1.0, + "type": "time_region", + "validated": False, + "created_by": created_by, + "shot_id": shot_id, + } + ] + + +@pytest.mark.asyncio +async def test_internal_token_accepted_for_import(auth_setup): + """PUT /annotations with the server-internal token should be accepted as admin.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + internal_token = get_internal_token() + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(created_by="alice"), + headers={"Authorization": f"Bearer {internal_token}"}, + ) + assert resp.status_code == 200 + + +@pytest.mark.asyncio +async def test_no_token_rejected_for_import_in_auth_mode(auth_setup): + """PUT /annotations with no token must be rejected when auth is required.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, _ = await create_project_and_sample(client, admin_token) + + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(), + ) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_import_non_admin_created_by_overwritten(auth_setup): + """An annotator importing with a spoofed created_by should have it replaced.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + alice_token = await get_auth_token(client, "alice", "alice_pass") + + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(created_by="bob"), + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + # Verify: annotation stored as alice, not bob + get_resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert get_resp.status_code == 200 + annotations = get_resp.json() + assert len(annotations) == 1 + assert annotations[0]["created_by"] == "alice" + + +@pytest.mark.asyncio +async def test_import_admin_can_set_arbitrary_created_by(auth_setup): + """A global admin may import annotations attributed to any user.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(created_by="alice"), + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + get_resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + annotations = get_resp.json() + assert len(annotations) == 1 + assert annotations[0]["created_by"] == "alice" + + +@pytest.mark.asyncio +async def test_internal_token_preserves_arbitrary_created_by(auth_setup): + """The internal token (Ray worker) can import with model:: prefixed created_by.""" + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + internal_token = get_internal_token() + resp = await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(created_by="model::disruption_cnn"), + headers={"Authorization": f"Bearer {internal_token}"}, + ) + assert resp.status_code == 200 + + get_resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + annotations = get_resp.json() + assert len(annotations) == 1 + assert annotations[0]["created_by"] == "model::disruption_cnn" + + +@pytest.mark.asyncio +async def test_username_with_model_prefix_rejected(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + resp = await client.post( + "/users", + json={ + "username": "model::disruption_cnn", + "password": "pass123", + "email": "", + "global_role": "user", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_username_with_dunder_prefix_rejected(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + resp = await client.post( + "/users", + json={ + "username": "__internal__", + "password": "pass123", + "email": "", + "global_role": "user", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_user_save_does_not_corrupt_model_prefixed_predictions(auth_setup): + """A human user named 'disruption_cnn' saving annotations must NOT delete + model predictions stored as 'model::disruption_cnn'. The prefix is the separator. + """ + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id, sample_id = await create_project_and_sample(client, admin_token) + + # Create a human user whose name matches a model type (the collision scenario). + create_resp = await client.post( + "/users", + json={ + "username": "disruption_cnn", + "password": "pass123", + "global_role": "user", + }, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert create_resp.status_code == 200 + + # Insert a model prediction via the internal token. + internal_token = get_internal_token() + await client.put( + f"/projects/{project_id}/annotations", + json=annotation_payload(label="model_pred", created_by="model::disruption_cnn"), + headers={"Authorization": f"Bearer {internal_token}"}, + ) + + # The human user saves their own annotation for the same sample. + await client.post( + f"/projects/{project_id}/members", + json={"username": "disruption_cnn", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + human_token = await get_auth_token(client, "disruption_cnn", "pass123") + save_resp = await client.put( + f"/projects/{project_id}/samples/{sample_id}/annotations", + json=[ + { + "label": "human_ann", + "time_min": 0.0, + "time_max": 1.0, + "type": "time_region", + "validated": True, + "created_by": "placeholder", + } + ], + headers={"Authorization": f"Bearer {human_token}"}, + ) + assert save_resp.status_code == 200 + + # Both the model prediction and human annotation must survive — the model:: + # prefix provides complete namespace separation. + get_resp = await client.get( + f"/projects/{project_id}/annotations", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + annotations = get_resp.json() + labels_by_author = {a["created_by"]: a["label"] for a in annotations} + assert labels_by_author.get("model::disruption_cnn") == "model_pred" + assert labels_by_author.get("disruption_cnn") == "human_ann" diff --git a/tests/api/auth/test_users_router.py b/tests/api/auth/test_users_router.py new file mode 100644 index 000000000..ef0ccc74d --- /dev/null +++ b/tests/api/auth/test_users_router.py @@ -0,0 +1,266 @@ +"""Integration tests for /users and /projects/{id}/members endpoints.""" + +import pytest + +from tests.api.auth.conftest import get_auth_token + + +async def create_project(client, token): + resp = await client.post( + "/projects", + json={ + "name": "test_project", + "task": "time-series", + "query_strategy": "sequential", + "data_loader": "tabular", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200, resp.text + return resp.json()["_id"] + + +@pytest.mark.asyncio +async def test_list_users_as_admin(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "admin", "admin_pass") + response = await client.get("/users", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 200 + users = response.json() + usernames = [u["username"] for u in users] + assert "admin" in usernames + assert "alice" in usernames + assert "bob" in usernames + + +@pytest.mark.asyncio +async def test_list_users_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + response = await client.get("/users", headers={"Authorization": f"Bearer {token}"}) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_create_user_as_admin(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "admin", "admin_pass") + response = await client.post( + "/users", + json={ + "username": "newuser", + "password": "newpass123", + "email": "new@test.com", + "global_role": "user", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + body = response.json() + # Endpoint returns {"_id": ""} + assert "_id" in body + assert len(body["_id"]) > 0 + + +@pytest.mark.asyncio +async def test_create_user_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + response = await client.post( + "/users", + json={ + "username": "sneaky", + "password": "pass", + "email": "", + "global_role": "admin", + }, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_get_user_by_id_self(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + alice_id = auth_setup["alice_id"] + response = await client.get( + f"/users/{alice_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert response.json()["username"] == "alice" + + +@pytest.mark.asyncio +async def test_get_other_user_as_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + bob_id = auth_setup["bob_id"] + response = await client.get( + f"/users/{bob_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_own_user(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + alice_id = auth_setup["alice_id"] + response = await client.put( + f"/users/{alice_id}", + json={"email": "alice_new@test.com"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + + # Verify the update via GET /users/{alice_id} + get_resp = await client.get( + f"/users/{alice_id}", headers={"Authorization": f"Bearer {token}"} + ) + assert get_resp.status_code == 200 + assert get_resp.json()["email"] == "alice_new@test.com" + + +@pytest.mark.asyncio +async def test_update_other_user_as_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + bob_id = auth_setup["bob_id"] + response = await client.put( + f"/users/{bob_id}", + json={"email": "hacked@evil.com"}, + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_delete_user_as_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "alice", "alice_pass") + bob_id = auth_setup["bob_id"] + response = await client.delete( + f"/users/{bob_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_delete_user_as_admin(auth_setup): + client = auth_setup["client"] + token = await get_auth_token(client, "admin", "admin_pass") + bob_id = auth_setup["bob_id"] + response = await client.delete( + f"/users/{bob_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + + # Bob can no longer log in + login_resp = await client.post( + "/auth/token", + data={"username": "bob", "password": "bob_pass"}, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + assert login_resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_add_and_list_project_members(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id = await create_project(client, admin_token) + + # Add alice as annotator (uses username, not user_id) + resp = await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert resp.status_code == 200 + + # List members + list_resp = await client.get( + f"/projects/{project_id}/members", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert list_resp.status_code == 200 + members = list_resp.json() + usernames = [m["username"] for m in members] + assert "alice" in usernames + + +@pytest.mark.asyncio +async def test_add_member_non_admin_forbidden(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + alice_token = await get_auth_token(client, "alice", "alice_pass") + project_id = await create_project(client, admin_token) + + resp = await client.post( + f"/projects/{project_id}/members", + json={"username": "bob", "role": "annotator"}, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 403 + + +@pytest.mark.asyncio +async def test_update_member_show_others_annotations(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + alice_token = await get_auth_token(client, "alice", "alice_pass") + project_id = await create_project(client, admin_token) + + # Add alice as annotator (uses username, not user_id) + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + # Alice updates her own show_others_annotations preference + resp = await client.put( + f"/projects/{project_id}/members/{auth_setup['alice_id']}", + json={"show_others_annotations": False}, + headers={"Authorization": f"Bearer {alice_token}"}, + ) + assert resp.status_code == 200 + + # Verify the DB value changed + members_resp = await client.get( + f"/projects/{project_id}/members", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + alice_member = next(m for m in members_resp.json() if m["username"] == "alice") + assert alice_member["show_others_annotations"] is False + + +@pytest.mark.asyncio +async def test_remove_project_member(auth_setup): + client = auth_setup["client"] + admin_token = await get_auth_token(client, "admin", "admin_pass") + project_id = await create_project(client, admin_token) + + await client.post( + f"/projects/{project_id}/members", + json={"username": "alice", "role": "annotator"}, + headers={"Authorization": f"Bearer {admin_token}"}, + ) + + del_resp = await client.delete( + f"/projects/{project_id}/members/{auth_setup['alice_id']}", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + assert del_resp.status_code == 200 + + list_resp = await client.get( + f"/projects/{project_id}/members", + headers={"Authorization": f"Bearer {admin_token}"}, + ) + usernames = [m["username"] for m in list_resp.json()] + assert "alice" not in usernames diff --git a/tests/api/routers/test_annotations.py b/tests/api/routers/test_annotations.py index 54f5b38be..646c1a123 100644 --- a/tests/api/routers/test_annotations.py +++ b/tests/api/routers/test_annotations.py @@ -232,9 +232,9 @@ async def test_create_annotations(api_client, setup_db, db_client): ) assert response.status_code == 200 - # Check they have been added to database + # Check they have been added to database (existing annotations from other users are preserved) annotations = await db_client.get_all_documents("annotations") - assert len(annotations) == 7 + assert len(annotations) == 8 db_annotations = await db_client.get_filtered_documents( "annotations", filters={"sample_id": ObjectId(setup_db[sample_id])} ) @@ -246,6 +246,8 @@ async def test_create_annotations(api_client, setup_db, db_client): if annotation["label"] == in_annotation["label"] ) for key, value in in_annotation.items(): + if key == "created_by": + continue # server overwrites created_by with authenticated user assert db_annotation[key] == value assert db_annotation.get("timestamp") diff --git a/tests/api/routers/test_models.py b/tests/api/routers/test_models.py index 32eeee861..d790e3ed8 100644 --- a/tests/api/routers/test_models.py +++ b/tests/api/routers/test_models.py @@ -2,7 +2,6 @@ pytest.importorskip("ray") -import pathlib from toktagger.api.schemas.models import ModelUpdate from toktagger.api.models.base import ActorRegistry from toktagger.api.core.sender import ( @@ -344,11 +343,7 @@ async def test_model_update(api_client, db_client, setup_model_db): @pytest.mark.asyncio @pytest.mark.models_enabled -async def test_model_start_training_no_params( - api_client, - db_client, - setup_model_db, -): +async def test_model_start_training_no_params(api_client, db_client, setup_model_db): response = await api_client.put( f"/projects/{setup_model_db['project_id']}/models/mock_disruption_cnn/train" ) @@ -422,9 +417,7 @@ async def test_model_missing_params(api_client, db_client, setup_model_db, metho @pytest.mark.asyncio @pytest.mark.models_enabled -async def test_model_start_training_params( - api_client, db_client, setup_model_db, settings -): +async def test_model_start_training_params(api_client, db_client, setup_model_db): response = await api_client.put( f"/projects/{setup_model_db['project_id']}/models/mock_params_timeseries_cnn/train", json={ @@ -476,16 +469,17 @@ async def test_model_delete_type(api_client, db_client, setup_model_db): assert not config.settings.models.cache_dir.joinpath( f"{setup_model_db['model_id_2']}.model" ).exists() - # And for model 3 it does still exist + # And for model 3 and 4 it does still exist assert config.settings.models.cache_dir.joinpath( f"{setup_model_db['model_id_3']}.model" ).exists() assert config.settings.models.cache_dir.joinpath( - f"{setup_model_db['model_id_3']}.model" + f"{setup_model_db['model_id_4']}.model" ).exists() @pytest.mark.asyncio +@pytest.mark.models_enabled async def test_model_delete_type_version(api_client, db_client, setup_model_db): response = await api_client.delete( f"/projects/{setup_model_db['project_id']}/models/mock_disruption_cnn?version=2" @@ -565,7 +559,7 @@ async def test_model_stop_training_not_in_progress( @pytest.mark.models_enabled async def test_model_delete_predictions(api_client, db_client, setup_model_db): await api_client.delete( - f"/projects/{setup_model_db['project_id']}/models/disruption_cnn/predict" + f"/projects/{setup_model_db['project_id']}/models/mock_disruption_cnn/predict" ) # Should be 5 annotations remaining since half were created by 'manual' @@ -578,14 +572,14 @@ async def test_model_delete_predictions(api_client, db_client, setup_model_db): @pytest.mark.models_enabled async def test_model_delete_no_predictions(api_client, db_client, setup_model_db): response = await api_client.delete( - f"/projects/{setup_model_db['project_id']}/models/mock_disruption_cnn/predict" + f"/projects/{setup_model_db['project_id']}/models/mock_timeseries_cnn/predict" ) # Nothing created by this model, so should return 404 and not delete anything assert response.status_code == 404 assert ( response.json()["detail"] - == "No annotations produced by mock_disruption_cnn could be found for this Project." + == "No annotations produced by mock_timeseries_cnn could be found for this Project." ) annotations = await db_client.get_all_documents(collection="annotations") assert len(annotations) == 10 @@ -629,9 +623,7 @@ async def test_model_load_local(api_client, db_client, setup_model_db): assert model["progress"] == 100 # Check model has been saved after completion - model_path = pathlib.Path(config.settings.models.cache_dir).joinpath( - f"{model_id}.model" - ) + model_path = config.settings.models.cache_dir.joinpath(f"{model_id}.model") assert model_path.exists() # Open the file, check contents are there @@ -707,8 +699,6 @@ async def test_model_load_local_failed(api_client, db_client, setup_model_db): assert model["training_status"] == "failed" # Check model has not been saved after completion - assert ( - not pathlib.Path(config.settings.models.cache_dir) - .joinpath(f"{model_id}.model") - .exists() - ) + assert not config.settings.models.cache_dir.joinpath( + f"{model_id}.model" + ).exists() diff --git a/tests/conftest.py b/tests/conftest.py index 22894775c..f7181b420 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,3 +1,5 @@ +import pathlib +import tempfile import pytest import pytest_asyncio from toktagger.api.main import Server @@ -10,10 +12,8 @@ import multiprocessing import requests import time -import tempfile -import toktagger.api.config as config import importlib -import pathlib +import toktagger.api.config as config MODELS_ENABLED = importlib.util.find_spec("ray") is not None @@ -36,23 +36,36 @@ def check_models_status(request): from toktagger.api.models.base import ActorRegistry else: - error_msg = """ You have attempted to run a test which uses a fixture that requires models, - but the models optional dependencies (Ray) are not installed, - and this test was not marked as a 'models_enabled' test. - Please review the fixture usage of this test, or mark it accurately. - """ + _error_msg = ( + "You have attempted to run a test which uses a fixture that requires models, " + "but the models optional dependencies (Ray) are not installed, " + "and this test was not marked as a 'models_enabled' test. " + "Please review the fixture usage of this test, or mark it accurately." + ) @pytest.fixture() def ray_session(): - raise pytest.UsageError(error_msg) + raise pytest.UsageError(_error_msg) @pytest.fixture() def setup_model_samples(): - raise pytest.UsageError(error_msg) + raise pytest.UsageError(_error_msg) @pytest.fixture() def setup_model_db(): - raise pytest.UsageError(error_msg) + raise pytest.UsageError(_error_msg) + + +try: + import ray + from toktagger.api.models.base import ModelRegistry, WorkerRegistry + + _models_available = True +except Exception: + _models_available = False + ModelRegistry = None + WorkerRegistry = None + ray = None @pytest.fixture(scope="session") @@ -74,19 +87,24 @@ def uda_test(uda_env_vars): @pytest.fixture(scope="session") def settings(): + """Session-scoped config object with temp dirs for models storage. + + Required by ray_session (models_fixtures.py) for MODEL_STORAGE env var. + Also patches the module-level config.settings so model fixtures that + reference config.settings.models.cache_dir work correctly. + """ with tempfile.TemporaryDirectory(suffix="toktagger_") as tempd: - pathlib.Path(tempd).joinpath("models").mkdir(exist_ok=True) - settings = config.Settings( + models_dir = pathlib.Path(tempd) / "models" + models_dir.mkdir(exist_ok=True) + s = config.Settings( server=config.Server(cache_dir=tempd), - models=config.Models( - cache_dir=pathlib.Path(tempd).joinpath("models"), max_actors=1 - ), + models=config.Models(cache_dir=models_dir, max_actors=1), database=config.Database(mongo_url="./toktagger_test_db"), uda=config.UDA(), sal=config.SAL(), ) - config.settings = settings - yield settings + config.settings = s + yield s @pytest_asyncio.fixture(scope="function") @@ -94,6 +112,7 @@ async def db_client(settings): db_client = MongoDBClient( settings.database.mongo_url, "annotate_db", settings.server.cache_dir ) + yield db_client await db_client.delete_filtered_documents("projects") @@ -105,21 +124,14 @@ async def db_client(settings): @pytest_asyncio.fixture(scope="function") async def api_client(db_client): - # Have hit various issues getting this setup - # Using fastAPI TestClient() doesn't play well with async pymongo as it tries to do stuff in different event loops - # So have to use this AsyncClient from httpx, but this no longer just accepts an app - # So have to wrap it in this Transport thing, but that for some reason doesnt run the lifespan in the app - # So have to run this manually, however trying to run the close after the yield to close the db connection gives errors - # So am just going to leave it open, since the db container will be deleted after anyway - # Any alternative solution ideas are welcome..... - os.environ["API_URL"] = "http://test" + os.environ["API_URL"] = "" server = Server() server.testing_mode = True - os.environ["API_URL"] = "" server._setup_app() app = server.app app.state.db_client = db_client app.state.project = None + app.state.auth_required = False if MODELS_ENABLED: app.state.task_registry = ActorRegistry(max_actors=1) app.state.task_registry.tasks["abc123"] = "Ray Task Object" @@ -184,6 +196,7 @@ async def setup_db(db_client): ids={"project_id": ObjectId(project_id_2), "sample_id": ObjectId(sample_id_4)}, ) await asyncio.sleep(0.01) + yield { "project_id_1": project_id_1, "project_id_2": project_id_2, @@ -226,8 +239,7 @@ async def setup_db_small(db_client): def run_server(): - # Import to register mock model - + os.environ["TOKTAGGER_AUTH_REQUIRED"] = "false" server = Server() server.testing_mode = True server.run() @@ -239,7 +251,7 @@ def start_server(settings): proc.start() # Wait for server to start server_up = False - for t in range(60): + for t in range(600): try: response = requests.get( "http://localhost:8002/health", diff --git a/tests/end_to_end/__init__.py b/tests/end_to_end/__init__.py index 2eeb9e63a..1e51bda2a 100644 --- a/tests/end_to_end/__init__.py +++ b/tests/end_to_end/__init__.py @@ -1,4 +1,8 @@ -from playwright.sync_api import Page, expect +try: + from playwright.sync_api import Page, expect +except ImportError: + Page = None # type: ignore[assignment,misc] + expect = None # type: ignore[assignment] def form_check(page: Page, submit_button_name): diff --git a/tests/end_to_end/test_projects_page.py b/tests/end_to_end/test_projects_page.py index 08eb47837..3060ea73d 100644 --- a/tests/end_to_end/test_projects_page.py +++ b/tests/end_to_end/test_projects_page.py @@ -1,3 +1,6 @@ +import pytest + +pytest.importorskip("playwright") import requests from playwright.sync_api import Page, expect import re diff --git a/tests/end_to_end/test_sample_view_page.py b/tests/end_to_end/test_sample_view_page.py index a3934b219..36bb61cba 100644 --- a/tests/end_to_end/test_sample_view_page.py +++ b/tests/end_to_end/test_sample_view_page.py @@ -1,3 +1,6 @@ +import pytest + +pytest.importorskip("playwright") from playwright.sync_api import Page, expect import pathlib from tests.endpoints import ( diff --git a/tests/end_to_end/test_samples_page.py b/tests/end_to_end/test_samples_page.py index f9c11c63c..fc78e4cee 100644 --- a/tests/end_to_end/test_samples_page.py +++ b/tests/end_to_end/test_samples_page.py @@ -1,3 +1,6 @@ +import pytest + +pytest.importorskip("playwright") from playwright.sync_api import Page, expect import re from datetime import datetime diff --git a/tests/end_to_end/test_timeseries_page.py b/tests/end_to_end/test_timeseries_page.py index 8e76a6c33..bfad24c4f 100644 --- a/tests/end_to_end/test_timeseries_page.py +++ b/tests/end_to_end/test_timeseries_page.py @@ -1,3 +1,6 @@ +import pytest + +pytest.importorskip("playwright") from playwright.sync_api import Page, expect import pathlib from tests.endpoints import create_project, create_local_samples, create_model_samples @@ -355,7 +358,7 @@ def test_timeseries_save_annotations(server_setup, page: Page): assert len(annotations) == 2 for annotation in annotations: - assert annotation["created_by"] == "manual" + assert annotation["created_by"] == "admin" assert annotation["validated"] # == True assert annotation["uncertainty"] == 0 diff --git a/tests/models_fixtures.py b/tests/models_fixtures.py index e20fabc0c..01056d46d 100644 --- a/tests/models_fixtures.py +++ b/tests/models_fixtures.py @@ -52,7 +52,7 @@ def setup_model_samples(): validated=True, label="Disruption", time=disruption_time, - created_by="manual" if i < 9985 else "disruption_cnn", + created_by="manual" if i < 9985 else "model::mock_disruption_cnn", ) samples.append( diff --git a/toktagger/api/asgi.py b/toktagger/api/asgi.py new file mode 100644 index 000000000..599af0666 --- /dev/null +++ b/toktagger/api/asgi.py @@ -0,0 +1,12 @@ +"""ASGI entry point for Gunicorn + UvicornWorker. + +Usage: + gunicorn toktagger.api.asgi:app \ + --worker-class uvicorn.workers.UvicornWorker \ + --workers 4 \ + --bind 0.0.0.0:8002 +""" + +from toktagger.api.cli import create_app + +app = create_app() diff --git a/toktagger/api/auth/__init__.py b/toktagger/api/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/toktagger/api/auth/core.py b/toktagger/api/auth/core.py new file mode 100644 index 000000000..9b3dd78f4 --- /dev/null +++ b/toktagger/api/auth/core.py @@ -0,0 +1,80 @@ +import hashlib +import os +import secrets +from datetime import timedelta +from pathlib import Path + +from itsdangerous import URLSafeTimedSerializer, BadSignature, SignatureExpired +from platformdirs import user_cache_dir + +ACCESS_TOKEN_EXPIRE_SECONDS = 60 * 60 * 24 # 24 hours +_SALT = "toktagger-auth-v1" + +_serializer: URLSafeTimedSerializer | None = None +_internal_token: str | None = None + + +def get_internal_token() -> str: + """Return a stable per-process internal token for trusted server-to-server calls.""" + global _internal_token + if _internal_token is None: + _internal_token = secrets.token_urlsafe(32) + return _internal_token + + +def _get_serializer() -> URLSafeTimedSerializer: + global _serializer + if _serializer is not None: + return _serializer + + env_key = os.environ.get("AUTH_SECRET_KEY") + if env_key: + secret = env_key + else: + cache_dir = Path(user_cache_dir("toktagger", "ukaea")) + cache_dir.mkdir(parents=True, exist_ok=True) + key_file = cache_dir / "secret.key" + if key_file.exists(): + secret = key_file.read_text().strip() + else: + secret = secrets.token_hex(32) + key_file.write_text(secret) + + _serializer = URLSafeTimedSerializer(secret, salt=_SALT) + return _serializer + + +def _pbkdf2_hash(password: str, salt_hex: str) -> str: + dk = hashlib.pbkdf2_hmac( + "sha256", password.encode(), bytes.fromhex(salt_hex), 260000 + ) + return dk.hex() + + +def hash_password(plain: str) -> str: + salt = secrets.token_hex(16) + hashed = _pbkdf2_hash(plain, salt) + return f"pbkdf2:{salt}:{hashed}" + + +def verify_password(plain: str, stored: str) -> bool: + if not stored.startswith("pbkdf2:"): + return False + try: + _, salt, expected = stored.split(":") + except ValueError: + return False + return secrets.compare_digest(_pbkdf2_hash(plain, salt), expected) + + +def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str: + return _get_serializer().dumps(data) + + +def decode_token(token: str) -> dict: + try: + return _get_serializer().loads(token, max_age=ACCESS_TOKEN_EXPIRE_SECONDS) + except SignatureExpired: + raise ValueError("Token has expired") + except BadSignature: + raise ValueError("Invalid token") diff --git a/toktagger/api/auth/dependencies.py b/toktagger/api/auth/dependencies.py new file mode 100644 index 000000000..4c7682a46 --- /dev/null +++ b/toktagger/api/auth/dependencies.py @@ -0,0 +1,135 @@ +from fastapi import Depends, HTTPException, Request +from fastapi.security import OAuth2PasswordBearer + +from toktagger.api.auth.core import decode_token, get_internal_token +from toktagger.api.schemas import convert_to_objectid +from toktagger.api.schemas.users import UserOut + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/token", auto_error=False) + +_PASSTHROUGH_USER = UserOut( + id="000000000000000000000000", + username="admin", + email="", + global_role="admin", + is_active=True, +) + +_INTERNAL_USER = UserOut( + id="000000000000000000000001", + username="__internal__", + email="", + global_role="admin", + is_active=True, +) + + +async def get_current_user( + request: Request, + token: str | None = Depends(oauth2_scheme), +) -> UserOut: + # Passthrough mode: auth disabled (e.g. first-install with no users yet). + # Default True means auth IS required — the safe default. + if not getattr(request.app.state, "auth_required", True): + return _PASSTHROUGH_USER + + if token is None: + raise HTTPException(status_code=401, detail="Not authenticated") + + # Internal server-to-server token used by Ray worker callbacks (sender.py). + if token == get_internal_token(): + return _INTERNAL_USER + + try: + payload = decode_token(token) + username: str = payload.get("sub") + if not username: + raise ValueError("missing sub") + except ValueError: + raise HTTPException(status_code=401, detail="Invalid or expired token") + + from toktagger.api.crud import utils + + db_client = request.app.state.db_client + user = await utils.get_user_by_username(db_client, username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if not user.is_active: + raise HTTPException(status_code=403, detail="Account is inactive") + return user + + +async def require_global_admin( + current_user: UserOut = Depends(get_current_user), +) -> UserOut: + if current_user.global_role != "admin": + raise HTTPException(status_code=403, detail="Admin access required") + return current_user + + +async def get_project_membership( + project_id: str, + request: Request, + current_user: UserOut = Depends(get_current_user), +) -> dict | None: + """Return the membership record, or None for global admins (unrestricted).""" + if current_user.global_role == "admin": + return None + + db_client = request.app.state.db_client + project_oid = convert_to_objectid(project_id, "projects") + user_oid = convert_to_objectid(current_user.id, "users") + + docs = await db_client.get_filtered_documents( + "project_members", + filters={"project_id": project_oid, "user_id": user_oid}, + ) + if not docs: + raise HTTPException( + status_code=403, detail="You are not a member of this project" + ) + return docs[0] + + +async def require_project_viewer( + membership: dict | None = Depends(get_project_membership), + current_user: UserOut = Depends(get_current_user), +) -> UserOut: + """Any project member (viewer, annotator, admin) may access read-only resources.""" + return current_user + + +async def require_project_annotator( + membership: dict | None = Depends(get_project_membership), + current_user: UserOut = Depends(get_current_user), +) -> UserOut: + if current_user.global_role == "admin": + return current_user + # Reject any role that is not explicitly allowed to write (viewer or unknown future roles) + if membership and membership.get("role") not in ("admin", "annotator"): + raise HTTPException( + status_code=403, detail="Viewers cannot create or modify annotations" + ) + return current_user + + +async def require_project_admin_role( + project_id: str, + request: Request, + current_user: UserOut = Depends(get_current_user), +) -> UserOut: + if current_user.global_role == "admin": + return current_user + + from toktagger.api.crud import utils + + db_client = request.app.state.db_client + membership = await utils.get_project_membership( + db_client, project_id, current_user.id + ) + if not membership or membership.get("role") != "admin": + raise HTTPException( + status_code=403, + detail="Project admin access required", + ) + return current_user diff --git a/toktagger/api/auth/first_run.py b/toktagger/api/auth/first_run.py new file mode 100644 index 000000000..20d4890f4 --- /dev/null +++ b/toktagger/api/auth/first_run.py @@ -0,0 +1,42 @@ +import secrets +from pathlib import Path +from filelock import FileLock +from platformdirs import user_cache_dir +from toktagger.api.auth.core import hash_password +from toktagger.api.schemas.users import UserIn + + +async def ensure_admin_user(db_client) -> bool: + """Create the default admin user on first run. + + Returns True if auth is required (users exist after this call). + """ + lock_path = Path(user_cache_dir("toktagger", "ukaea")) / "first_run.lock" + lock_path.parent.mkdir(parents=True, exist_ok=True) + + with FileLock(str(lock_path), timeout=30): + users = await db_client.get_all_documents("users") + if users: + return True + + password = secrets.token_urlsafe(12) + admin = UserIn( + username="admin", + hashed_password=hash_password(password), + email="", + global_role="admin", + is_active=True, + ) + await db_client.insert(collection="users", model=admin) + + border = "=" * 52 + print(f"\n{border}") + print(" TokTagger: first-run setup") + print(" Admin account created") + print(" Username : admin") + print(f" Password : {password}") + print(" ⚠ This password will not be shown again.") + print(" Please change it after first login.") + print(f"{border}\n") + + return True diff --git a/toktagger/api/cli.py b/toktagger/api/cli.py index cf1b6681b..7f15d3ab0 100644 --- a/toktagger/api/cli.py +++ b/toktagger/api/cli.py @@ -1,11 +1,13 @@ import webbrowser import argparse +import subprocess +import sys from toktagger.api.main import Server -import toktagger.api.config as config from toktagger.api.models import models_dependencies_installed import uvicorn import time import threading +import os # Need to point to app as a module level string if we want reload option @@ -20,7 +22,8 @@ def create_app(): def do_open_browser(host: str, port: int): time.sleep(1) # allow server to start - webbrowser.open(f"http://{host}:{port}/ui/projects") + display_host = "localhost" if host == "0.0.0.0" else host + webbrowser.open(f"http://{display_host}:{port}/ui/projects") def main(): @@ -33,11 +36,9 @@ def main(): """) argparser = argparse.ArgumentParser(description="Run the FastAPI application") + argparser.add_argument("--host", default="0.0.0.0", help="Host to run the app on") argparser.add_argument( - "--host", help="Host to run the app on, by default localhost" - ) - argparser.add_argument( - "--port", type=int, help="Port to run the app on, by default 8002" + "--port", default=8002, type=int, help="Port to run the app on" ) argparser.add_argument( "--no-browser", action="store_true", help="Don't open a browser" @@ -45,27 +46,47 @@ def main(): argparser.add_argument( "--reload", action="store_true", - help="Reload the API on changes, by default False", + help="Reload the API on changes (single-worker uvicorn only)", + ) + argparser.add_argument( + "--workers", + default=4, + type=int, + help="Number of Gunicorn worker processes (use 1 for single-worker uvicorn dev mode)", ) args = argparser.parse_args() open_browser = not args.no_browser if open_browser: threading.Thread(target=do_open_browser, args=(args.host, args.port)).start() - if args.host: - config.settings.server.host = args.host - if args.port: - config.settings.server.port = args.port - if args.reload: - config.settings.server.reload = args.reload + os.environ["API_URL"] = f"http://{args.host}:{args.port}" - uvicorn.run( - "toktagger.api.cli:create_app", - factory=True, - host=config.settings.server.host, - port=config.settings.server.port, - reload=config.settings.server.reload, - ) + if args.workers > 1: + if args.reload: + print("Warning: --reload is ignored when --workers > 1 (gunicorn mode)") + subprocess.run( + [ + sys.executable, + "-m", + "gunicorn", + "toktagger.api.asgi:app", + "--worker-class", + "uvicorn.workers.UvicornWorker", + "--workers", + str(args.workers), + "--bind", + f"{args.host}:{args.port}", + ], + check=True, + ) + else: + uvicorn.run( + "toktagger.api.cli:create_app", + factory=True, + host=args.host, + port=args.port, + reload=args.reload, + ) if __name__ == "__main__": diff --git a/toktagger/api/core/sender.py b/toktagger/api/core/sender.py index 21e264e0d..a067e29a5 100644 --- a/toktagger/api/core/sender.py +++ b/toktagger/api/core/sender.py @@ -28,7 +28,15 @@ def send_updates( else: payload = updates.model_dump(mode="json") - response = requests.put(url=url, json=payload) + headers = {} + # API_TOKEN is a shared HMAC secret generated by the server at startup and injected into + # Ray worker processes via environment variable. It authenticates as the __internal__ + # pseudo-user (global admin). Workers running on separate machines should communicate + # over TLS (e.g. behind a reverse proxy) to protect this token in transit. + if api_token := os.environ.get("API_TOKEN"): + headers["Authorization"] = f"Bearer {api_token}" + + response = requests.put(url=url, json=payload, headers=headers) return response diff --git a/toktagger/api/crud/db.py b/toktagger/api/crud/db.py index 5fc99f65c..f98fb88fe 100644 --- a/toktagger/api/crud/db.py +++ b/toktagger/api/crud/db.py @@ -19,17 +19,24 @@ def __init__(self, url: str, db_name: str, cache_dir: str | None = None): # Use mongodb (expects running instance of mongodb at this address) self.client = pymongo.AsyncMongoClient(url) else: - if not cache_dir: - cache_dir = user_cache_dir("toktagger", "ukaea") - cache_dir = Path(cache_dir) - cache_dir.mkdir(parents=True, exist_ok=True) - file_name = cache_dir / db_name - self.client = AsyncMongitaClient(file_name) + # File-path mode: cache_dir takes priority, else fall back to url as a base + # directory path (backward-compatibility for users who configured a filesystem + # path as the DB URL before the cache_dir option was added). + if cache_dir: + base_dir = Path(cache_dir) + elif url and url != "default": + base_dir = Path(url) + else: + base_dir = Path(user_cache_dir("toktagger", "ukaea")) + base_dir.mkdir(parents=True, exist_ok=True) + self.client = AsyncMongitaClient(str(base_dir / db_name)) self.db = self.client[db_name] async def insert( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], model: T, ids: dict[str, ObjectId] | None = None, ): @@ -41,7 +48,9 @@ async def insert( async def insert_many( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], models: list[T], ids: typing.Union[dict, list[dict]] | None = None, ): @@ -62,7 +71,9 @@ async def insert_many( async def update( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], model: T, object_id: ObjectId, ): @@ -81,7 +92,9 @@ async def update( async def get_document_by_id( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], object_id: ObjectId, ): return await self.db[collection].find_one({"_id": object_id}) @@ -94,7 +107,9 @@ async def get_all_documents( async def get_filtered_documents( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], filters: dict = {}, sort_by: str = "_id", sort_direction: typing.Literal["ascending", "descending"] = "descending", @@ -114,7 +129,9 @@ async def get_filtered_documents( async def delete_filtered_documents( self, - collection: typing.Literal["projects", "annotations", "models", "samples"], + collection: typing.Literal[ + "projects", "annotations", "models", "samples", "users", "project_members" + ], filters: dict = {}, ): return await self.db[collection].delete_many(filters) diff --git a/toktagger/api/crud/mongita_client.py b/toktagger/api/crud/mongita_client.py index 118eb88d8..0b8e8ba1c 100644 --- a/toktagger/api/crud/mongita_client.py +++ b/toktagger/api/crud/mongita_client.py @@ -1,34 +1,41 @@ import asyncio +import os import re -from typing import Any, AsyncIterator, Dict, Iterable, List, Optional, Tuple +from typing import Any, AsyncIterator, Callable, Dict, Iterable, List, Optional, Tuple +from filelock import FileLock from mongita import MongitaClientDisk -class AsyncMutex: - def __init__(self) -> None: - self._alock = asyncio.Lock() - self._loop = asyncio.get_event_loop() - - async def __aenter__(self): - await self._alock.acquire() - return self - - async def __aexit__(self, exc_type, exc, tb): - self._alock.release() - - class AsyncMongitaClient: def __init__( self, db_path: str, ) -> None: + os.makedirs(db_path, exist_ok=True) self._client = MongitaClientDisk(db_path) self._closed = False - self._mutex = AsyncMutex() + self._mutex = asyncio.Lock() + self._file_lock = FileLock(db_path + ".lock") def __getitem__(self, name: str) -> "AsyncDatabase": return AsyncDatabase(self, name) + async def _run(self, fn: Callable) -> Any: + """Run fn in a thread, holding the cross-process FileLock. + + asyncio.Lock prevents multiple coroutines in this worker from + queuing up threads; FileLock prevents concurrent access from + other gunicorn workers. + """ + async with self._mutex: + file_lock = self._file_lock + + def _in_thread(): + with file_lock: + return fn() + + return await asyncio.to_thread(_in_thread) + async def close(self) -> None: if self._closed: return @@ -61,20 +68,23 @@ def name(self) -> str: return self._name async def insert_one(self, document: Dict[str, Any]) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread(self._sync_col.insert_one, document) + return await self._database._client._run( + lambda: self._sync_col.insert_one(document) + ) async def insert_many(self, documents: Iterable[Dict[str, Any]]) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread(self._sync_col.insert_many, list(documents)) + docs = list(documents) + return await self._database._client._run( + lambda: self._sync_col.insert_many(docs) + ) async def find_one( self, filter: Optional[Dict[str, Any]] = None, *args, **kwargs ) -> Optional[Dict[str, Any]]: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.find_one, filter or {}, *args, **kwargs - ) + f = filter or {} + return await self._database._client._run( + lambda: self._sync_col.find_one(f, *args, **kwargs) + ) def find( self, @@ -101,11 +111,11 @@ async def _snapshot() -> List[Dict[str, Any]]: else: mongo_filter[field] = condition - async with self._database._client._mutex: - cursor = await asyncio.to_thread( - self._sync_col.find, mongo_filter or {}, *args, **kwargs - ) - results = await asyncio.to_thread(lambda: list(cursor)) + def _do_find(): + cursor = self._sync_col.find(mongo_filter or {}, *args, **kwargs) + return list(cursor) + + results = await self._database._client._run(_do_find) # Apply regex filters if regex_filters: @@ -135,44 +145,39 @@ async def _snapshot() -> List[Dict[str, Any]]: async def update_one( self, filter: Dict[str, Any], update: Dict[str, Any], *args, **kwargs ) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.update_one, filter, update, *args, **kwargs - ) + return await self._database._client._run( + lambda: self._sync_col.update_one(filter, update, *args, **kwargs) + ) async def update_many( self, filter: Dict[str, Any], update: Dict[str, Any], *args, **kwargs ) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.update_many, filter, update, *args, **kwargs - ) + return await self._database._client._run( + lambda: self._sync_col.update_many(filter, update, *args, **kwargs) + ) async def delete_one(self, filter: Dict[str, Any], *args, **kwargs) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.delete_one, filter, *args, **kwargs - ) + return await self._database._client._run( + lambda: self._sync_col.delete_one(filter, *args, **kwargs) + ) async def delete_many(self, filter: Dict[str, Any], *args, **kwargs) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.delete_many, filter, *args, **kwargs - ) + return await self._database._client._run( + lambda: self._sync_col.delete_many(filter, *args, **kwargs) + ) async def count_documents( self, filter: Optional[Dict[str, Any]] = None, *args, **kwargs ) -> int: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.count_documents, filter or {}, *args, **kwargs - ) + f = filter or {} + return await self._database._client._run( + lambda: self._sync_col.count_documents(f, *args, **kwargs) + ) async def create_index(self, keys: Any, *args, **kwargs) -> Any: - async with self._database._client._mutex: - return await asyncio.to_thread( - self._sync_col.create_index, keys, *args, **kwargs - ) + return await self._database._client._run( + lambda: self._sync_col.create_index(keys, *args, **kwargs) + ) class AsyncCursor: diff --git a/toktagger/api/crud/utils.py b/toktagger/api/crud/utils.py index ecc0b440a..fd468738a 100644 --- a/toktagger/api/crud/utils.py +++ b/toktagger/api/crud/utils.py @@ -13,6 +13,15 @@ from toktagger.api.schemas.projects import Project from toktagger.api.schemas.samples import FileData, Sample, SampleUpdate, SampleSummary from toktagger.api.schemas.models import Model, ModelIn, ModelUpdate +from toktagger.api.auth.core import hash_password +from toktagger.api.schemas.users import ( + ProjectMember, + ProjectMemberOut, + ProjectMemberUpdate, + UserIn, + UserOut, + UserUpdate, +) async def get_projects( @@ -391,17 +400,20 @@ async def update_annotations( project_id: str, sample_id: str, annotations: list[AnnotationBatchTypes], + created_by: Optional[str] = None, ) -> list[str]: - # Delete previous annotations, if they exist + project_obj_id = convert_to_objectid(project_id, "projects") + sample_obj_id = convert_to_objectid(sample_id, "samples") + filters: dict = {"project_id": project_obj_id, "sample_id": sample_obj_id} + if created_by is not None: + # Scope the delete to only this user's annotations (concurrent-safe) + filters["created_by"] = created_by try: - await delete_annotations( - db_client=db_client, project_id=project_id, sample_id=sample_id - ) + await db_client.delete_filtered_documents("annotations", filters) except HTTPException: pass if len(annotations) == 0: - # Nothing to add! return [] return await add_annotations( @@ -517,3 +529,194 @@ async def import_annotations( await update_sample( db_client, sample_id, SampleUpdate(validated_annotations=False) ) + + +# --------------------------------------------------------------------------- +# User helpers +# --------------------------------------------------------------------------- + + +async def get_user_by_username( + db_client: MongoDBClient, username: str +) -> UserOut | None: + docs = await db_client.get_filtered_documents( + "users", filters={"username": username} + ) + return UserOut.model_validate(docs[0]) if docs else None + + +async def get_user_by_id(db_client: MongoDBClient, user_id: str) -> UserOut | None: + obj_id = convert_to_objectid(user_id, "users") + doc = await db_client.get_document_by_id("users", obj_id) + return UserOut.model_validate(doc) if doc else None + + +async def get_all_users(db_client: MongoDBClient) -> list[UserOut]: + docs = await db_client.get_all_documents("users") + return [UserOut.model_validate(d) for d in docs] + + +async def create_user(db_client: MongoDBClient, user: UserIn) -> str: + existing = await get_user_by_username(db_client, user.username) + if existing: + raise HTTPException(status_code=409, detail="Username already exists") + return await db_client.insert("users", user) + + +async def update_user( + db_client: MongoDBClient, user_id: str, updates: UserUpdate +) -> None: + obj_id = convert_to_objectid(user_id, "users") + user = await get_user_by_id(db_client, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + update_dict = updates.model_dump(exclude_none=True) + if "password" in update_dict: + update_dict["hashed_password"] = hash_password(update_dict.pop("password")) + await db_client.db["users"].update_one({"_id": obj_id}, {"$set": update_dict}) + + +async def delete_user(db_client: MongoDBClient, user_id: str) -> None: + obj_id = convert_to_objectid(user_id, "users") + result = await db_client.delete_filtered_documents("users", {"_id": obj_id}) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="User not found") + # Also remove their project memberships + await db_client.delete_filtered_documents("project_members", {"user_id": obj_id}) + + +# --------------------------------------------------------------------------- +# Project membership helpers +# --------------------------------------------------------------------------- + + +async def get_project_members( + db_client: MongoDBClient, project_id: str +) -> list[ProjectMemberOut]: + project_oid = convert_to_objectid(project_id, "projects") + docs = await db_client.get_filtered_documents( + "project_members", filters={"project_id": project_oid} + ) + result = [] + for doc in docs: + user = await get_user_by_id(db_client, str(doc["user_id"])) + doc["username"] = user.username if user else "unknown" + doc["user_id"] = str(doc["user_id"]) + result.append(ProjectMemberOut.model_validate(doc)) + return result + + +async def get_project_membership( + db_client: MongoDBClient, project_id: str, user_id: str +) -> dict | None: + project_oid = convert_to_objectid(project_id, "projects") + user_oid = convert_to_objectid(user_id, "users") + docs = await db_client.get_filtered_documents( + "project_members", + filters={"project_id": project_oid, "user_id": user_oid}, + ) + return docs[0] if docs else None + + +async def add_project_member( + db_client: MongoDBClient, + project_id: str, + user_id: str, + role: str = "annotator", +) -> str: + project_oid = convert_to_objectid(project_id, "projects") + user_oid = convert_to_objectid(user_id, "users") + + existing = await get_project_membership(db_client, project_id, user_id) + if existing: + raise HTTPException( + status_code=409, detail="User is already a member of this project" + ) + + member = ProjectMember( + project_id=str(project_oid), + user_id=str(user_oid), + role=role, + ) + return await db_client.insert( + "project_members", + member, + ids={"project_id": project_oid, "user_id": user_oid}, + ) + + +async def update_project_member( + db_client: MongoDBClient, + project_id: str, + user_id: str, + updates: ProjectMemberUpdate, +) -> None: + project_oid = convert_to_objectid(project_id, "projects") + user_oid = convert_to_objectid(user_id, "users") + + docs = await db_client.get_filtered_documents( + "project_members", + filters={"project_id": project_oid, "user_id": user_oid}, + ) + if not docs: + raise HTTPException(status_code=404, detail="Membership not found") + + member_oid = convert_to_objectid(str(docs[0]["_id"]), "project_members") + await db_client.update("project_members", updates, member_oid) + + +async def remove_project_member( + db_client: MongoDBClient, project_id: str, user_id: str +) -> None: + project_oid = convert_to_objectid(project_id, "projects") + user_oid = convert_to_objectid(user_id, "users") + result = await db_client.delete_filtered_documents( + "project_members", + {"project_id": project_oid, "user_id": user_oid}, + ) + if result.deleted_count == 0: + raise HTTPException(status_code=404, detail="Membership not found") + + +async def get_user_projects( + db_client: MongoDBClient, + user_id: str, + global_role: str, + name: Optional[str] = None, + sort_by: str = "_id", + sort_direction: str = "descending", + start: int = 0, + count: Optional[int] = None, +) -> list[Project]: + if global_role == "admin": + return await get_projects( + db_client, + name=name, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + count=count, + ) + + user_oid = convert_to_objectid(user_id, "users") + memberships = await db_client.get_filtered_documents( + "project_members", filters={"user_id": user_oid} + ) + project_oids = [m["project_id"] for m in memberships] + + if not project_oids: + return [] + + filters: dict = {"_id": {"$in": project_oids}} + if name: + filters["name"] = {"$regex": f"{name}", "$options": "i"} + + docs = await db_client.get_filtered_documents( + "projects", + filters=filters, + sort_by=sort_by, + sort_direction=sort_direction, + start=start, + limit=count if count is not None else 0, + ) + return [Project(**d) for d in docs] diff --git a/toktagger/api/main.py b/toktagger/api/main.py index 11dfbecb6..b061154f9 100644 --- a/toktagger/api/main.py +++ b/toktagger/api/main.py @@ -1,24 +1,25 @@ +import os import pathlib from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import uvicorn -import warnings -import tempfile from toktagger.api.routers.annotations import router as annotations_router from toktagger.api.routers.annotators import router as annotators_router +from toktagger.api.routers.auth import router as auth_router from toktagger.api.routers.data import router as data_router from toktagger.api.routers.models import router as models_router from toktagger.api.routers.projects import router as projects_router from toktagger.api.routers.samples import router as samples_router +from toktagger.api.routers.users import router as users_router from toktagger.api.routers.base import router as base_router from toktagger.api.routers.paths import router as paths_router from toktagger.api.routers.meta import router as meta_router from toktagger.api.core.data_loaders import LoaderRegistry from toktagger.api.crud.db import MongoDBClient +from toktagger.api.auth.first_run import ensure_admin_user from toktagger.api.models import models_dependencies_installed -import toktagger.api.config as config # Only import large packages if models dependencies installed if models_dependencies_installed(): @@ -32,14 +33,20 @@ @asynccontextmanager async def lifespan(app: FastAPI): + mongo_url = os.environ.get("MONGO_URL", "default") db_name = "annotate_db" + cache_dir = os.environ.get("DB_CACHE_DIR") - app.state.db_client = MongoDBClient( - str(config.settings.database.mongo_url), - db_name, - str(config.settings.server.cache_dir), - ) + app.state.db_client = MongoDBClient(mongo_url, db_name, cache_dir) app.state.project = None + + # Bootstrap admin user on first run; set auth_required flag. + # TOKTAGGER_AUTH_REQUIRED=false disables auth (tests only). + if os.environ.get("TOKTAGGER_AUTH_REQUIRED", "true").lower() == "false": + app.state.auth_required = False + else: + app.state.auth_required = await ensure_admin_user(app.state.db_client) + yield await app.state.db_client.client.close() @@ -51,57 +58,46 @@ def __init__(self): self.testing_mode = False def _setup_ray(self): + from toktagger.api.auth.core import get_internal_token + + if (api_url := os.environ.get("API_URL")) is None: + raise ValueError("API URL must be set!") if not ray.is_initialized(): ray.init( runtime_env={ "env_vars": { - "API_URL": f"http://{config.settings.server.host}:{config.settings.server.port}", - "MODEL_STORAGE": str(config.settings.models.cache_dir), + "API_URL": api_url, + "MODEL_STORAGE": os.environ.get("MODEL_STORAGE"), + "API_TOKEN": get_internal_token(), } }, ) - # Create a ray actor for use as a model registry WorkerRegistry.options( name="WorkerModelRegistry", lifetime="detached" ).remote(ModelRegistry._registry) - # And one for use as a dataloader registry WorkerRegistry.options( name="WorkerLoaderRegistry", lifetime="detached" ).remote(LoaderRegistry._registry) - # Create a task registry self.app.state.task_registry = ActorRegistry( - max_actors=config.settings.models.max_actors + max_actors=os.environ.get("MAX_ACTORS", 5) ) def _setup_app(self): - # Check cache dirs are in /tmp if testing mode enabled - if self.testing_mode: - tempdir = pathlib.Path(tempfile.gettempdir()) - if ( - tempdir not in config.settings.models.cache_dir.parents - or tempdir not in config.settings.server.cache_dir.parents - ): - raise ValueError( - "In testing mode, cache directories must be in temp directory!" - ) - self.app = FastAPI(lifespan=lifespan) - # Allow requests from the frontend dev server origins = [ "http://localhost:5173", ] self.app.add_middleware( CORSMiddleware, - allow_origins=origins, # or ["*"] to allow all + allow_origins=origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) - # Static front end files self.app.state.index_file = self.frontend_path / "index.html" self.app.state.testing_mode = self.testing_mode self.app.mount( @@ -110,6 +106,8 @@ def _setup_app(self): name="assets", ) + self.app.include_router(auth_router) + self.app.include_router(users_router) self.app.include_router(annotations_router) self.app.include_router(data_router) self.app.include_router(models_router) @@ -120,41 +118,13 @@ def _setup_app(self): self.app.include_router(meta_router) self.app.include_router(base_router) - def run(self, host: str | None = None, port: int | None = None): - """ - Launch the TokTagger server. - - Parameters - ---------- - host : str - DEPRECATED - use config file or environment variables instead. - The host to launch the server on, by default 'localhost' - port : int - DEPRECATED - use config file or environment variables instead. - The port to launch the server on, by default 8002 - """ - # Provide deprecation warning - if host or port: - warnings.warn( - """ - Specifying host and port within Server.run() is deprecated and will be removed in a future version. - Please provide these arguments via configuration file or environment variable instead. - See https://ukaea.github.io/toktagger/configuration for details. - """, - DeprecationWarning, - stacklevel=2, - ) - if host: - config.settings.server.host = host - if port: - config.settings.server.port = port - + def run( + self, + host: str = "localhost", + port: int = 8002, + ): + os.environ["API_URL"] = f"http://{host}:{port}" self._setup_app() - # Setup ray if required if models_dependencies_installed(): self._setup_ray() - uvicorn.run( - self.app, - host=config.settings.server.host, - port=config.settings.server.port, - ) + uvicorn.run(self.app, host=host, port=port) diff --git a/toktagger/api/models/base.py b/toktagger/api/models/base.py index 0220ae663..18ada36a8 100644 --- a/toktagger/api/models/base.py +++ b/toktagger/api/models/base.py @@ -18,6 +18,21 @@ if models_dependencies_installed(): import ray +else: + + class _RayStub: + @staticmethod + def remote(cls): + return cls + + class ObjectRef: + pass + + @staticmethod + def get_actor(name): + raise RuntimeError("Ray not installed") + + ray = _RayStub() # type: ignore[assignment] # Recursively walk through schema, finding things which need to be changed diff --git a/toktagger/api/routers/annotations.py b/toktagger/api/routers/annotations.py index 4a66f7c1a..c6a7c7fae 100644 --- a/toktagger/api/routers/annotations.py +++ b/toktagger/api/routers/annotations.py @@ -1,11 +1,17 @@ from typing import Literal -from fastapi import APIRouter, Request, Path, Query +from fastapi import APIRouter, Depends, Request, Path, Query +from toktagger.api.auth.dependencies import ( + require_project_annotator, + require_project_viewer, + require_project_admin_role, +) from toktagger.api.crud import utils from toktagger.api.schemas.samples import SampleUpdate from toktagger.api.schemas.annotations import ( AnnotationBatchTypes, AnnotationOutTypes, ) +from toktagger.api.schemas.users import UserOut router = APIRouter( prefix="/projects/{project_id}", @@ -26,36 +32,17 @@ async def get_all_annotations( project_id: str = Path( description="The ID of the project to retrieve annotations for" ), - sort_by: str = Query( - "_id", - description="Field to sort responses by, by default '_id' (equivalent to timestamp)", - ), - sort_direction: Literal["ascending", "descending"] = Query( - "descending", - description="Direction to sort responses, by default 'descending'", - ), - start: int = Query( - 0, - description="Index of the first annotation you want returned when sorted by above parameter", - ), - count: int = Query( - None, - description="The number of annotations to return, leave blank to return all entries", - ), - validated: bool = Query( - None, - description="Whether to return only validated or unvalidated annotations, leave blank for all annotations", - ), + sort_by: str = Query("_id"), + sort_direction: Literal["ascending", "descending"] = Query("descending"), + start: int = Query(0), + count: int = Query(None), + validated: bool = Query(None), + current_user: UserOut = Depends(require_project_viewer), ) -> list[AnnotationOutTypes]: - """ - Retrieve all annotations for this project, subject to specified filters. - ------------------------------------------------------------------------ - """ + """Retrieve all annotations for this project.""" db_client = request.app.state.db_client - # Check project exists await utils.get_project(db_client=db_client, project_id=project_id) - # Get annotations annotations = await utils.get_annotations( db_client=db_client, project_id=project_id, @@ -82,12 +69,15 @@ async def import_annotations( project_id: str = Path( description="The ID of the project to update annotations for" ), + current_user: UserOut = Depends(require_project_annotator), ) -> None: - """ - Update or add annotations for this project. - ------------------------------------------- - """ + """Update or add annotations for this project.""" db_client = request.app.state.db_client + # Non-admin, non-internal callers must own all annotations they import. + # Global admins and the internal Ray-worker token bypass this for data migration / predictions. + if current_user.username != "__internal__" and current_user.global_role != "admin": + for annotation in annotations: + annotation.created_by = current_user.username await utils.import_annotations(db_client, project_id, annotations) @@ -103,15 +93,11 @@ async def delete_all_annotations( project_id: str = Path( description="The ID of the project to delete all annotations for" ), + current_user: UserOut = Depends(require_project_admin_role), ): - """ - Delete ALL annotations for the given project. - --------------------------------------------- - """ + """Delete ALL annotations for the given project.""" db_client = request.app.state.db_client - # Check project exists await utils.get_project(db_client=db_client, project_id=project_id) - # Delete all annotations for this project await utils.delete_annotations(db_client=db_client, project_id=project_id) @@ -119,7 +105,7 @@ async def delete_all_annotations( "/samples/{sample_id}/annotations", response_model=list[AnnotationOutTypes], responses={ - 200: {"description": "Annotations for this sample deleted successfully."}, + 200: {"description": "Annotations for this sample returned successfully."}, 404: {"description": "Project or Sample not found with that ID."}, }, ) @@ -127,54 +113,45 @@ async def get_annotations( request: Request, project_id: str = Path(description="The ID of the project to get samples from."), sample_id: str = Path(description="The ID of the sample to get annotations from."), - sort_by: str = Query( - "_id", - description="Field to sort responses by, by default '_id' (equivalent to timestamp)", - ), - sort_direction: Literal["ascending", "descending"] = Query( - "descending", - description="Direction to sort responses, by default 'descending'", - ), - start: int = Query( - 0, - description="Index of the first annotation you want returned when sorted newest - oldest", - ), - count: int = Query( - None, - description="The number of annotations to return, leave blank to return all entries", - ), - validated: bool = Query( - None, - description="Whether to return only validated or unvalidated annotations, leave blank for all annotations", - ), - created_by: str = Query( - None, - description="Whether to only return annotations created by a specific model or by a human.", - ), + sort_by: str = Query("_id"), + sort_direction: Literal["ascending", "descending"] = Query("descending"), + start: int = Query(0), + count: int = Query(None), + validated: bool = Query(None), + created_by: str = Query(None), + current_user: UserOut = Depends(require_project_viewer), ) -> list[AnnotationOutTypes]: - # Return annotations available for this project and sample, if any - # Can filter by params, eg specific camera or frame being returned (or return all annotations for this sample at once and store client side?) - # Should return whether these are validated as a boolean db_client = request.app.state.db_client - # Check project and sample exist await utils.get_project(db_client=db_client, project_id=project_id) await utils.get_sample( db_client=db_client, project_id=project_id, sample_id=sample_id ) - # Get annotations + # Membership already validated by require_project_viewer; re-fetch only to check + # the per-user show_others_annotations preference (global admins get None → show all) + membership = None + if current_user.global_role != "admin": + membership = await utils.get_project_membership( + db_client, project_id, current_user.id + ) + + # Apply per-user annotation visibility filter + effective_created_by = created_by + if membership and not membership.get("show_others_annotations", True): + # Only show the current user's own annotations + effective_created_by = current_user.username + annotations = await utils.get_annotations( db_client=db_client, project_id=project_id, sample_id=sample_id, validated=validated, - created_by=created_by, + created_by=effective_created_by, sort_by=sort_by, sort_direction=sort_direction, start=start, count=count, ) - return annotations @@ -194,38 +171,30 @@ async def update_annotations( sample_id: str = Path( description="The ID of the sample to update annotations for." ), - validated: bool = Query( - None, - description="Whether to set sample to validated (useful if no annotations present).", - ), + validated: bool = Query(None), + current_user: UserOut = Depends(require_project_annotator), ): - """ - Update the list of annotations to a given sample for a specified project. Will overwrite existing annotations. - --------------------------------------------------------------------- - """ - # Add human annotations to this project and sample - # Again dont know what form this data will take so have set to a Request for now - # This data could be for one or more events per task, ie multiple ELMs or UFOs per pulse - # This should be added into the database, with validated=True - # Delete predictions from model, if they exist, since they are being replaced by human validated ones + """Update the annotations for a sample. Replaces only the current user's annotations.""" db_client = request.app.state.db_client - # Check project and sample exist await utils.get_project(db_client=db_client, project_id=project_id) sample = await utils.get_sample( db_client=db_client, project_id=project_id, sample_id=sample_id ) - # Set shot_id for each annotation + # Server is authoritative for identity for annotation in annotations: + annotation.created_by = current_user.username annotation.shot_id = sample.shot_id - # Delete previous annotations, if they exist, and add new ones result = await utils.update_annotations( - db_client, project_id, sample_id, annotations + db_client, + project_id, + sample_id, + annotations, + created_by=current_user.username, ) - # Update sample to show that annotations are validated if validated or any(annotation.validated for annotation in annotations): await utils.update_sample( db_client=db_client, @@ -249,22 +218,14 @@ async def remove_annotations( sample_id: str = Path( description="The ID of the sample to delete annotations from." ), + current_user: UserOut = Depends(require_project_admin_role), ): - """ - Delete ALL annotations for a given sample from a given project. - --------------------------------------------------------------- - """ - # Remove annotations for this project and sample - # Probably dont need to be able to specify params here, don't envisage how/why the UI would allow you to remove specific annotations - + """Delete ALL annotations for a given sample from a given project.""" db_client = request.app.state.db_client - # Check project and sample exist await utils.get_project(db_client=db_client, project_id=project_id) await utils.get_sample( db_client=db_client, project_id=project_id, sample_id=sample_id ) - - # Delete all annotations for this project and sample await utils.delete_annotations( db_client=db_client, project_id=project_id, sample_id=sample_id ) diff --git a/toktagger/api/routers/annotators.py b/toktagger/api/routers/annotators.py index 9a4f39613..13b3a6615 100644 --- a/toktagger/api/routers/annotators.py +++ b/toktagger/api/routers/annotators.py @@ -1,4 +1,8 @@ -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, Depends, Request, HTTPException +from toktagger.api.auth.dependencies import ( + require_project_viewer, + require_project_annotator, +) from toktagger.api.schemas.projects import Project, Task from toktagger.api.schemas.samples import Sample from toktagger.api.schemas.data import DataParamTypes @@ -6,6 +10,7 @@ AnnotatorParamTypes, AnnotatorTypes, ) +from toktagger.api.schemas.users import UserOut from toktagger.api.crud.utils import get_project, get_sample from toktagger.api.core.annotators import ANNOTATORS, ANNOTATORS_PER_TASK from toktagger.api.core.data_loaders import LoaderRegistry @@ -17,7 +22,11 @@ @router.get("/annotator") -async def get_annotators(request: Request, project_id: str): +async def get_annotators( + request: Request, + project_id: str, + current_user: UserOut = Depends(require_project_viewer), +): # Dunno if this is of any use pass @@ -30,6 +39,7 @@ async def create_annotations( annotator_type: AnnotatorTypes, annotator_params: AnnotatorParamTypes, data_params: DataParamTypes, + current_user: UserOut = Depends(require_project_annotator), ): # Use the specified annotator to label this sample for this project # Would use the datapool to load and process the data diff --git a/toktagger/api/routers/auth.py b/toktagger/api/routers/auth.py new file mode 100644 index 000000000..78b1ab5f7 --- /dev/null +++ b/toktagger/api/routers/auth.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.security import OAuth2PasswordRequestForm + +from toktagger.api.auth.core import create_access_token, verify_password +from toktagger.api.auth.dependencies import get_current_user +from toktagger.api.schemas.users import TokenResponse, UserOut + +router = APIRouter(prefix="/auth", tags=["Auth"]) + + +@router.post("/token", response_model=TokenResponse) +async def login( + request: Request, + form_data: OAuth2PasswordRequestForm = Depends(), +): + db_client = request.app.state.db_client + # Raw doc lookup is intentional: UserOut deliberately omits hashed_password. + docs = await db_client.get_filtered_documents( + "users", filters={"username": form_data.username} + ) + if not docs: + raise HTTPException(status_code=401, detail="Invalid username or password") + + user_doc = docs[0] + if not verify_password(form_data.password, user_doc.get("hashed_password", "")): + raise HTTPException(status_code=401, detail="Invalid username or password") + + if not user_doc.get("is_active", True): + raise HTTPException(status_code=403, detail="Account is inactive") + + token = create_access_token({"sub": user_doc["username"]}) + return TokenResponse(access_token=token) + + +@router.get("/me", response_model=UserOut) +async def get_me(current_user: UserOut = Depends(get_current_user)): + return current_user diff --git a/toktagger/api/routers/base.py b/toktagger/api/routers/base.py index a6ec5538c..a0b0cad3a 100644 --- a/toktagger/api/routers/base.py +++ b/toktagger/api/routers/base.py @@ -1,5 +1,5 @@ from fastapi import APIRouter, Request -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, RedirectResponse from importlib.metadata import version, PackageNotFoundError from toktagger.api.models import models_dependencies_installed from toktagger.api.crud import utils @@ -9,23 +9,25 @@ tags=["Base"], ) +_ALL_METHODS = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] -@router.get("/") + +@router.api_route("/", methods=_ALL_METHODS) def get_app(request: Request): - """Endpoint to serve the main SPA.""" - return FileResponse(request.app.state.index_file) + """Serve the main SPA.""" + if request.method in ("GET", "HEAD"): + return FileResponse(request.app.state.index_file) + return RedirectResponse(url="/", status_code=303) @router.get("/health") async def health_check(request: Request) -> dict: """Check the server is running correctly.""" - # Get version try: vers = version("toktagger") except PackageNotFoundError: vers = "unknown" - # Check db connection try: await utils.get_projects( db_client=request.app.state.db_client, @@ -34,7 +36,6 @@ async def health_check(request: Request) -> dict: except Exception: db_conn = False - # Return info return { "name": "TokTagger", "version": vers, @@ -44,9 +45,13 @@ async def health_check(request: Request) -> dict: } -@router.get("/{full_path:path}") +@router.api_route("/{full_path:path}", methods=_ALL_METHODS) def spa_fallback(request: Request, full_path: str): - """Fallback route to serve the SPA's index.html for any unmatched routes. - This ensures that refreshing pages on the frontend takes the user to the same place. + """Fallback for SPA routing — serves index.html on GET, redirects other methods. + + Without this, a native form POST to any SPA path (e.g. /ui/login) would return + 405 because Starlette sees the path match but the GET-only method doesn't match. """ - return FileResponse(request.app.state.index_file) + if request.method in ("GET", "HEAD"): + return FileResponse(request.app.state.index_file) + return RedirectResponse(url=f"/{full_path}", status_code=303) diff --git a/toktagger/api/routers/data.py b/toktagger/api/routers/data.py index b33938197..51c0aa422 100644 --- a/toktagger/api/routers/data.py +++ b/toktagger/api/routers/data.py @@ -1,11 +1,13 @@ from typing import Optional +from toktagger.api.auth.dependencies import require_project_viewer from toktagger.api.core.views import DATA_VIEWS from toktagger.api.core.data_loaders import LoaderRegistry from toktagger.api.crud import utils from toktagger.api.schemas.data import DataResponseType, DataParams, DataParamTypes +from toktagger.api.schemas.users import UserOut from toktagger.api.schemas.views import ViewParams, ViewParamTypes -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request from toktagger.api.core.data_loaders import DataLoaderError @@ -19,6 +21,7 @@ async def get_data( request: Request, project_id: str, sample_id: str, + current_user: UserOut = Depends(require_project_viewer), params: Optional[DataParamTypes] = DataParams(), view: Optional[ViewParamTypes] = ViewParams(), ) -> DataResponseType: diff --git a/toktagger/api/routers/meta.py b/toktagger/api/routers/meta.py index 57e846be5..1f986ec61 100644 --- a/toktagger/api/routers/meta.py +++ b/toktagger/api/routers/meta.py @@ -1,4 +1,5 @@ from fastapi import APIRouter, Request, Depends +from toktagger.api.auth.dependencies import get_current_user from toktagger.api.core.data_loaders import LoaderRegistry from toktagger.api.schemas.models import LoadTypes from toktagger.api.models import models_dependencies_installed, check_models_enabled @@ -7,8 +8,14 @@ if models_dependencies_installed(): from toktagger.api.models.base import ModelRegistry +else: + ModelRegistry = None -router = APIRouter(prefix="/meta", tags=["Metadata"]) +router = APIRouter( + prefix="/meta", + tags=["Metadata"], + dependencies=[Depends(get_current_user)], +) @router.get("/dataloader") diff --git a/toktagger/api/routers/models.py b/toktagger/api/routers/models.py index dca77fe46..dce607370 100644 --- a/toktagger/api/routers/models.py +++ b/toktagger/api/routers/models.py @@ -2,10 +2,16 @@ from fastapi.responses import JSONResponse import random from bson.objectid import ObjectId +from toktagger.api.auth.dependencies import ( + require_project_viewer, + require_project_annotator, + require_project_admin_role, +) from toktagger.api.crud import utils from toktagger.api.schemas.annotations import AnnotationBatchTypes from toktagger.api.schemas.data import DataParamTypes, DataParams from toktagger.api.schemas.models import Model, ModelIn, ModelUpdate, LoadTypes +from toktagger.api.schemas.users import UserOut from toktagger.api.models import models_dependencies_installed, check_models_enabled from pydantic import ValidationError from collections import defaultdict @@ -17,6 +23,8 @@ from toktagger.api.worker import load_model, train_model, get_predictions from toktagger.api.models.base import ModelRegistry import ray +else: + ModelRegistry = None import logging @@ -58,6 +66,7 @@ def validate_model_params(model_type: str, schema_type: str, params: dict): async def get_models( request: Request, project_id: str = Path(description="The ID of the project to get models for."), + current_user: UserOut = Depends(require_project_viewer), start: int = Query( 0, description="Index of the first model you want returned when sorted by version", @@ -84,6 +93,7 @@ async def get_models( async def get_model( request: Request, project_id: str = Path(description="The ID of the project to get models for."), + current_user: UserOut = Depends(require_project_viewer), model_type: str = Path( description="The type of model to return information about." ), @@ -103,6 +113,7 @@ async def get_model( async def delete_models( request: Request, project_id: str = Path(description="The ID of the project to get models for."), + current_user: UserOut = Depends(require_project_admin_role), model_type: str = Path(description="The type of model to delete."), version: int = Query( None, @@ -144,7 +155,10 @@ async def delete_models( @router.get("/models/{model_type}/train") async def get_training_info( - request: Request, project_id: str, model_type: str + request: Request, + project_id: str, + model_type: str, + current_user: UserOut = Depends(require_project_viewer), ) -> Model: db_client = request.app.state.db_client await utils.get_project(db_client, project_id) @@ -163,6 +177,7 @@ async def start_model_training( request: Request, project_id: str, model_type: str, + current_user: UserOut = Depends(require_project_annotator), params: dict = Body( {}, description="Optional parameters for training the model", embed=True ), @@ -267,6 +282,7 @@ async def stop_model_training( request: Request, project_id: str, model_type: str, + current_user: UserOut = Depends(require_project_annotator), version: int | None = Query( None, description="Version of model to use, leave blank for latest version" ), @@ -495,6 +511,7 @@ async def get_load_model_status( async def predict( request: Request, project_id: str = Path(description="The ID of the project to get models for."), + current_user: UserOut = Depends(require_project_annotator), model_type: str = Path(description="The type of model to use for predictions."), version: int = Query( None, description="Version of model to use, leave blank for latest version" @@ -578,6 +595,7 @@ async def predict( async def delete_predictions( request: Request, project_id: str = Path(description="The ID of the project to get models for."), + current_user: UserOut = Depends(require_project_admin_role), model_type: str = Path(description="The type of model to delete predictions from."), ): db_client = request.app.state.db_client @@ -593,7 +611,10 @@ async def delete_predictions( result = await request.app.state.db_client.delete_filtered_documents( collection="annotations", - filters={"project_id": ObjectId(project.id), "created_by": model_type}, + filters={ + "project_id": ObjectId(project.id), + "created_by": f"model::{model_type}", + }, ) if result.deleted_count == 0: @@ -612,6 +633,7 @@ async def create_sample_predictions( sample_id: str = Path( description="The ID of the sample to make model predictions for." ), + current_user: UserOut = Depends(require_project_annotator), model_type: str = Path(description="The type of model to make predictions from."), params: dict = Body( {}, description="Optional parameters for training the model", embed=True @@ -663,6 +685,7 @@ async def get_sample_predictions( sample_id: str = Path( description="The ID of the sample to get model predictions for." ), + current_user: UserOut = Depends(require_project_viewer), model_type: str = Path(description="The type of model to get predictions from."), task_id: str = Path(description="The prediction task to get results from."), ) -> list[AnnotationBatchTypes]: @@ -738,6 +761,7 @@ async def update_model( project_id: str = Path( description="The ID of the project to make model predictions for." ), + current_user: UserOut = Depends(require_project_annotator), model_id: str = Path( description="The ID of the model to update information about." ), @@ -751,7 +775,11 @@ async def update_model( @router.get("/models/{model_id}/evaluate") -async def evaluate(project_id: str, model_id: str): +async def evaluate( + project_id: str, + model_id: str, + current_user: UserOut = Depends(require_project_viewer), +): # Get evaluation of model by comparing model predictions to human evaluations # Specify samples to use via filters # Return overall statistics, as well as correct/incorrect for each sample ID diff --git a/toktagger/api/routers/paths.py b/toktagger/api/routers/paths.py index 57522da7e..d6a7f8487 100644 --- a/toktagger/api/routers/paths.py +++ b/toktagger/api/routers/paths.py @@ -1,7 +1,10 @@ -from fastapi import APIRouter, Request +from fastapi import APIRouter, Depends, Request +from toktagger.api.auth.dependencies import get_current_user from toktagger.api.crud import utils -router = APIRouter(prefix="/paths", tags=["Paths"]) +router = APIRouter( + prefix="/paths", tags=["Paths"], dependencies=[Depends(get_current_user)] +) @router.get("/files", response_model=list[str]) diff --git a/toktagger/api/routers/projects.py b/toktagger/api/routers/projects.py index b2039adcd..4abfa4399 100644 --- a/toktagger/api/routers/projects.py +++ b/toktagger/api/routers/projects.py @@ -1,47 +1,38 @@ -from toktagger.api.schemas.projects import Project, ProjectIn from typing import Literal -from fastapi import APIRouter, Request, HTTPException, Query, Path +from fastapi import APIRouter, Depends, Request, HTTPException, Query, Path +from toktagger.api.auth.dependencies import ( + get_current_user, + require_global_admin, + require_project_viewer, + require_project_admin_role, +) from toktagger.api.crud import utils from toktagger.api.core.data_loaders import LoaderRegistry from toktagger.api.crud.db import MongoDBClient +from toktagger.api.schemas import convert_to_objectid +from toktagger.api.schemas.projects import Project, ProjectIn +from toktagger.api.schemas.users import UserOut router = APIRouter(prefix="/projects", tags=["Projects"]) @router.get( - "", - responses={ - 200: {"description": "Returns a list of available Projects."}, - }, + "", responses={200: {"description": "Returns a list of available Projects."}} ) async def get_projects( request: Request, - sort_by: str = Query( - "_id", - description="Field to sort responses by, by default '_id' (equivalent to timestamp)", - ), - sort_direction: Literal["ascending", "descending"] = Query( - "descending", - description="Direction to sort responses, by default 'descending'", - ), - start: int = Query( - 0, - description="Index of the first project you want returned when sorted by above parameter", - ), - count: int | None = Query( - None, - description="Number of projects you want returned, leave blank to return all entries", - ), - name: str | None = Query( - None, description="Name of a project to search for, by default None" - ), + sort_by: str = Query("_id"), + sort_direction: Literal["ascending", "descending"] = Query("descending"), + start: int = Query(0), + count: int | None = Query(None), + name: str | None = Query(None), + current_user: UserOut = Depends(get_current_user), ) -> list[Project]: - """ - Get a list of all available projects. - ------------------------------------- - """ - projects = await utils.get_projects( + """Get a list of projects visible to the current user.""" + return await utils.get_user_projects( db_client=request.app.state.db_client, + user_id=current_user.id, + global_role=current_user.global_role, name=name, sort_by=sort_by, sort_direction=sort_direction, @@ -49,98 +40,78 @@ async def get_projects( count=count, ) - return projects - -@router.post( - "", - responses={ - 200: { - "description": "Project has been created successfully, returning the Project's ID." - }, - }, -) -async def create_project(request: Request, project: ProjectIn): - """ - Create a new project. - --------------------- - """ - # Create instance of this project class, instantiating all required classes for that task, and return its ID - # In the future, should be able to specify eg dataloader, data type, query strategy etc +@router.post("", responses={200: {"description": "Project created successfully."}}) +async def create_project( + request: Request, + project: ProjectIn, + current_user: UserOut = Depends(get_current_user), +): + """Create a new project and auto-add the creator as project admin.""" if project.data_loader not in LoaderRegistry.names(): raise HTTPException(422, detail="Invalid data loader specified.") - print(project) + db_client: MongoDBClient = request.app.state.db_client + project_id = await db_client.insert(collection="projects", model=project) - _id = await request.app.state.db_client.insert(collection="projects", model=project) - return {"_id": _id} + # Auto-add creator as project admin + await utils.add_project_member(db_client, project_id, current_user.id, role="admin") + + return {"_id": project_id} @router.get( "/{project_id}", responses={ - 200: {"description": "Project has been retrieved successfully."}, + 200: {"description": "Project retrieved successfully."}, 404: {"description": "Project not found with that ID."}, }, ) async def get_project( request: Request, project_id: str = Path(description="The ID of the project to return"), + current_user: UserOut = Depends(require_project_viewer), ) -> Project: - """ - Get a single project using its ID. - ----------------------------------- - """ - # Return information about a specific project - # Have put project_id as a string for now, but might want to use ShortUUID? - db_client = request.app.state.db_client - project = await utils.get_project(db_client, project_id) - - if not project: - raise HTTPException(status_code=404, detail="Project not found with that ID.") - - return project + """Get a single project using its ID.""" + return await utils.get_project(request.app.state.db_client, project_id) @router.put( "/{project_id}", responses={ - 200: { - "description": "Project has been successfully set as the active project." - }, + 200: {"description": "Project updated successfully."}, 404: {"description": "Project not found with that ID."}, }, ) async def update_project( request: Request, project: Project, - project_id: str = Path(description="The ID of the project to activate"), + project_id: str = Path(description="The ID of the project to update"), + current_user: UserOut = Depends(require_project_admin_role), ): - """Update a project's information. - ----------------------------- - """ - db_client: MongoDBClient = request.app.state.db_client - await utils.update_project(db_client, project_id, project) + """Update a project's information.""" + await utils.update_project(request.app.state.db_client, project_id, project) @router.delete( "/{project_id}", responses={ - 200: {"description": "Project has been successfully deleted."}, + 200: {"description": "Project deleted successfully."}, 404: {"description": "Project not found with that ID."}, }, ) async def delete_project( request: Request, project_id: str = Path(description="The ID of the project to delete"), + current_user: UserOut = Depends(require_project_admin_role), ): - """ - Permanently delete a project. - ----------------------------- - """ + """Permanently delete a project.""" db_client = request.app.state.db_client - # Delete this specific project await utils.delete_projects(db_client=db_client, project_id=project_id) + project_oid = convert_to_objectid(project_id, "projects") + await db_client.delete_filtered_documents( + "project_members", {"project_id": project_oid} + ) @router.delete( @@ -151,11 +122,7 @@ async def delete_project( ) async def delete_all_projects( request: Request, + _: UserOut = Depends(require_global_admin), ): - """ - Remove all projects. - -------------------- - """ - db_client = request.app.state.db_client - # Check project exists - await utils.delete_projects(db_client=db_client) + """Remove all projects.""" + await utils.delete_projects(db_client=request.app.state.db_client) diff --git a/toktagger/api/routers/samples.py b/toktagger/api/routers/samples.py index 1a9284135..0fe696972 100644 --- a/toktagger/api/routers/samples.py +++ b/toktagger/api/routers/samples.py @@ -1,4 +1,9 @@ -from fastapi import APIRouter, Request, HTTPException, Query, Path, Body +from fastapi import APIRouter, Depends, Request, HTTPException, Query, Path, Body +from toktagger.api.auth.dependencies import ( + require_project_viewer, + require_project_annotator, + require_project_admin_role, +) from toktagger.api.core.query_strategy import QUERY_STRATEGIES from toktagger.api.crud import utils from toktagger.api.schemas.samples import ( @@ -9,6 +14,7 @@ ) from toktagger.api.schemas.annotations import Annotation from toktagger.api.schemas import convert_to_objectid +from toktagger.api.schemas.users import UserOut from typing import Literal import logging @@ -28,6 +34,7 @@ async def get_samples( request: Request, project_id: str = Path(description="The ID of the project to get samples for."), + current_user: UserOut = Depends(require_project_viewer), sort_by: str = Query( "_id", description="Field to sort responses by, by default '_id' (equivalent to timestamp)", @@ -80,16 +87,12 @@ async def add_samples( project_id: str = Path( description="The project ID to associate these samples with." ), + current_user: UserOut = Depends(require_project_annotator), ): """ Add a list of samples (with optional annotations) to this project. ------------------------------------------------------------------ """ - # Add samples from the range specified to the project - # I'm assuming these will be shot/pulse numbers, hence int, but could be unique ID strings instead - # Depends if for us a 'sample' will always be a shot/pulse, or if it could be a subset eg a single frame of video - # Do we also want to allow a single value, or list of specific value? - print(samples) project_obj_id = convert_to_objectid(project_id, "projects") if not await request.app.state.db_client.get_document_by_id( "projects", project_obj_id @@ -184,6 +187,7 @@ async def update_samples( project_id: str = Path( description="The project ID to associate these samples with." ), + current_user: UserOut = Depends(require_project_annotator), ): """ Update a list of samples (provided with their IDs) for this project. @@ -216,6 +220,7 @@ async def update_samples( async def get_next_sample( request: Request, project_id: str = Path(description="The project to return the next sample from."), + current_user: UserOut = Depends(require_project_viewer), visited_sample_ids: list[str] = Body( ..., description="The IDs of the samples already seen in this session." ), @@ -261,6 +266,7 @@ async def get_sample_summary( project_id: str = Path( description="The ID of the project to get a summary of samples from." ), + current_user: UserOut = Depends(require_project_viewer), ) -> SampleSummary: """Get a summary of samples for this project. @@ -285,6 +291,7 @@ async def get_sample( description="The ID of the project to retrieve a sample from." ), sample_id: str = Path(description="The ID of the sample to retrieve."), + current_user: UserOut = Depends(require_project_viewer), ) -> Sample: """ Get the specified sample from this project. @@ -313,6 +320,7 @@ async def remove_sample( description="The ID of the project to delete a sample from." ), sample_id: str = Path(description="The ID of the sample to delete."), + current_user: UserOut = Depends(require_project_admin_role), ): """ Get the specified sample from this project. @@ -340,6 +348,7 @@ async def remove_all_samples( project_id: str = Path( description="The ID of the project to delete all samples from." ), + current_user: UserOut = Depends(require_project_admin_role), ): """ Remove all samples from this project. diff --git a/toktagger/api/routers/users.py b/toktagger/api/routers/users.py new file mode 100644 index 000000000..5c96cffe3 --- /dev/null +++ b/toktagger/api/routers/users.py @@ -0,0 +1,178 @@ +from fastapi import APIRouter, Depends, HTTPException, Path, Request + +from toktagger.api.auth.core import hash_password +from toktagger.api.auth.dependencies import ( + get_current_user, + require_global_admin, + require_project_admin_role, +) +from toktagger.api.crud import utils +from toktagger.api.schemas.users import ( + ProjectMemberCreate, + ProjectMemberOut, + ProjectMemberUpdate, + UserCreate, + UserIn, + UserOut, + UserUpdate, +) + +router = APIRouter(tags=["Users"]) + + +# --------------------------------------------------------------------------- +# Global user management +# --------------------------------------------------------------------------- + + +@router.get("/users", response_model=list[UserOut]) +async def list_users( + request: Request, + _: UserOut = Depends(require_global_admin), +): + return await utils.get_all_users(request.app.state.db_client) + + +@router.post("/users", response_model=dict) +async def create_user( + request: Request, + body: UserCreate, + _: UserOut = Depends(require_global_admin), +): + # Reserved prefixes protect the internal worker namespace and model annotation namespace. + # NOTE: model predictions currently don't set created_by="model::" — this is a known + # gap that should be addressed in the models sender. + if body.username.startswith("model::") or body.username.startswith("__"): + raise HTTPException(status_code=422, detail="Username uses a reserved prefix") + user = UserIn( + username=body.username, + hashed_password=hash_password(body.password), + email=body.email, + global_role=body.global_role, + ) + user_id = await utils.create_user(request.app.state.db_client, user) + return {"_id": user_id} + + +@router.get("/users/{user_id}", response_model=UserOut) +async def get_user( + request: Request, + user_id: str = Path(...), + current_user: UserOut = Depends(get_current_user), +): + if current_user.global_role != "admin" and current_user.id != user_id: + raise HTTPException(status_code=403, detail="Access denied") + user = await utils.get_user_by_id(request.app.state.db_client, user_id) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return user + + +@router.put("/users/{user_id}") +async def update_user( + request: Request, + body: UserUpdate, + user_id: str = Path(...), + current_user: UserOut = Depends(get_current_user), +): + if current_user.global_role != "admin" and current_user.id != user_id: + raise HTTPException(status_code=403, detail="Access denied") + + db_client = request.app.state.db_client + + # Prevent demoting or deactivating the last active admin + if body.global_role == "user" or body.is_active is False: + all_users = await utils.get_all_users(db_client) + remaining_admins = [ + u + for u in all_users + if u.global_role == "admin" and u.is_active and u.id != user_id + ] + if not remaining_admins: + raise HTTPException( + status_code=422, + detail="Cannot demote or deactivate the last active admin account", + ) + + await utils.update_user(db_client, user_id, body) + + +@router.delete("/users/{user_id}") +async def delete_user( + request: Request, + user_id: str = Path(...), + _: UserOut = Depends(require_global_admin), +): + await utils.delete_user(request.app.state.db_client, user_id) + + +# --------------------------------------------------------------------------- +# Project membership management +# --------------------------------------------------------------------------- + + +@router.get( + "/projects/{project_id}/members", + response_model=list[ProjectMemberOut], +) +async def list_project_members( + request: Request, + project_id: str = Path(...), + current_user: UserOut = Depends(get_current_user), +): + db_client = request.app.state.db_client + if current_user.global_role != "admin": + membership = await utils.get_project_membership( + db_client, project_id, current_user.id + ) + if not membership: + raise HTTPException(status_code=403, detail="Not a member of this project") + return await utils.get_project_members(db_client, project_id) + + +@router.post("/projects/{project_id}/members", response_model=dict) +async def add_project_member( + request: Request, + body: ProjectMemberCreate, + project_id: str = Path(...), + current_user: UserOut = Depends(require_project_admin_role), +): + db_client = request.app.state.db_client + user = await utils.get_user_by_username(db_client, body.username) + if not user: + raise HTTPException(status_code=404, detail="User not found") + member_id = await utils.add_project_member( + db_client, project_id, user.id, body.role + ) + return {"_id": member_id} + + +@router.put("/projects/{project_id}/members/{user_id}") +async def update_project_member( + request: Request, + body: ProjectMemberUpdate, + project_id: str = Path(...), + user_id: str = Path(...), + current_user: UserOut = Depends(get_current_user), +): + db_client = request.app.state.db_client + + # Project admins can change any member; non-admins may only update their own preferences + if current_user.id != user_id and current_user.global_role != "admin": + membership = await utils.get_project_membership( + db_client, project_id, current_user.id + ) + if not membership or membership.get("role") != "admin": + raise HTTPException(status_code=403, detail="Project admin access required") + + await utils.update_project_member(db_client, project_id, user_id, body) + + +@router.delete("/projects/{project_id}/members/{user_id}") +async def remove_project_member( + request: Request, + project_id: str = Path(...), + user_id: str = Path(...), + current_user: UserOut = Depends(require_project_admin_role), +): + await utils.remove_project_member(request.app.state.db_client, project_id, user_id) diff --git a/toktagger/api/run.py b/toktagger/api/run.py index ff7a08dba..48be33a41 100644 --- a/toktagger/api/run.py +++ b/toktagger/api/run.py @@ -1,11 +1,34 @@ +import subprocess import uvicorn -import toktagger.api.config as config +import os if __name__ == "__main__": - uvicorn.run( - "toktagger.api.cli:create_app", - factory=True, - host=config.settings.server.host, - port=config.settings.server.port, - reload=config.settings.server.reload, - ) + host = os.environ.get("HOST", "0.0.0.0") + port = int(os.environ.get("PORT", 8002)) + workers = int(os.environ.get("WORKERS", 1)) + reload = os.environ.get("RELOAD", "false").lower() == "true" + + os.environ["API_URL"] = f"http://{host}:{port}" + + if workers > 1: + subprocess.run( + [ + "gunicorn", + "toktagger.api.asgi:app", + "--worker-class", + "uvicorn.workers.UvicornWorker", + "--workers", + str(workers), + "--bind", + f"{host}:{port}", + ], + check=True, + ) + else: + uvicorn.run( + "toktagger.api.cli:create_app", + factory=True, + host=host, + port=port, + reload=reload, + ) diff --git a/toktagger/api/schemas/users.py b/toktagger/api/schemas/users.py new file mode 100644 index 000000000..dee8f2358 --- /dev/null +++ b/toktagger/api/schemas/users.py @@ -0,0 +1,66 @@ +from typing import Literal, Optional +from pydantic import BaseModel, Field +from toktagger.api.schemas import ConfiguredModel + + +class UserBase(ConfiguredModel): + """Shared fields for user models.""" + + email: str = "" + global_role: Literal["admin", "user"] = "user" + is_active: bool = True + + +class UserIn(UserBase): + username: str + hashed_password: str + + +class UserOut(UserBase): + id: str = Field(..., alias="_id") + username: str + + +class UserCreate(BaseModel): + username: str + password: str + email: str = "" + global_role: Literal["admin", "user"] = "user" + + +class UserUpdate(BaseModel): + email: Optional[str] = None + global_role: Optional[Literal["admin", "user"]] = None + is_active: Optional[bool] = None + password: Optional[str] = None + + +class ProjectMember(ConfiguredModel): + project_id: str + user_id: str + role: Literal["admin", "annotator", "viewer"] = "annotator" + show_others_annotations: bool = True + + +class ProjectMemberOut(ConfiguredModel): + id: str = Field(..., alias="_id") + project_id: str + user_id: str + username: str + role: Literal["admin", "annotator", "viewer"] + show_others_annotations: bool + + +class ProjectMemberCreate(BaseModel): + username: str + role: Literal["admin", "annotator", "viewer"] = "annotator" + + +class ProjectMemberUpdate(BaseModel): + role: Optional[Literal["admin", "annotator", "viewer"]] = None + show_others_annotations: Optional[bool] = None + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/toktagger/api/static/assets/index-90ZQu-dw.js b/toktagger/api/static/assets/index-90ZQu-dw.js deleted file mode 100644 index 5efb2cefd..000000000 --- a/toktagger/api/static/assets/index-90ZQu-dw.js +++ /dev/null @@ -1,1323 +0,0 @@ -import{a as e,i as t,n,r,t as i}from"./rolldown-runtime-Cyuzqnbw.js";import{n as a,t as o}from"./react-CgZ-lftd.js";import{a as s,d as c,f as l,i as u,l as d,n as f,p,s as m,t as h}from"./plotly-Dyu4Cbe3.js";(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var g=i((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,O());else{var t=n(l);t!==null&&j(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function E(){return g?!0:!(e.unstable_now()-Tt&&E());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&j(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?O():S=!1}}}var O;if(typeof y==`function`)O=function(){y(D)};else if(typeof MessageChannel<`u`){var k=new MessageChannel,A=k.port2;k.port1.onmessage=D,O=function(){A.postMessage(null)}}else O=function(){_(D,0)};function j(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,j(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,O()))),r},e.unstable_shouldYield=E,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),_=i(((e,t)=>{t.exports=g()})),v=i((e=>{var t=a();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=v()})),b=i((e=>{var t=_(),n=a(),r=y();function i(e){var t=`https://react.dev/errors/`+e;if(1te||(e.current=ee[te],ee[te]=null,te--)}function re(e,t){te++,ee[te]=e.current,e.current=t}var ie=ne(null),ae=ne(null),oe=ne(null),se=ne(null);function ce(e,t){switch(re(oe,t),re(ae,e),re(ie,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Pd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Pd(t),e=Fd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}z(ie),re(ie,e)}function le(){z(ie),z(ae),z(oe)}function ue(e){e.memoizedState!==null&&re(se,e);var t=ie.current,n=Fd(t,e.type);t!==n&&(re(ae,e),re(ie,n))}function de(e){ae.current===e&&(z(ie),z(ae)),se.current===e&&(z(se),Bf._currentValue=R)}var fe,pe;function me(e){if(fe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);fe=t&&t[1]||``,pe=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{he=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?me(n):``}function _e(e,t){switch(e.tag){case 26:case 27:case 5:return me(e.type);case 16:return me(`Lazy`);case 13:return e.child!==t&&t!==null?me(`Suspense Fallback`):me(`Suspense`);case 19:return me(`SuspenseList`);case 0:case 15:return ge(e.type,!1);case 11:return ge(e.type.render,!1);case 1:return ge(e.type,!0);case 31:return me(`Activity`);default:return``}}function ve(e){try{var t=``,n=null;do t+=_e(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var ye=Object.prototype.hasOwnProperty,be=t.unstable_scheduleCallback,xe=t.unstable_cancelCallback,Se=t.unstable_shouldYield,Ce=t.unstable_requestPaint,we=t.unstable_now,Te=t.unstable_getCurrentPriorityLevel,Ee=t.unstable_ImmediatePriority,De=t.unstable_UserBlockingPriority,Oe=t.unstable_NormalPriority,ke=t.unstable_LowPriority,Ae=t.unstable_IdlePriority,je=t.log,Me=t.unstable_setDisableYieldValue,Ne=null,Pe=null;function Fe(e){if(typeof je==`function`&&Me(e),Pe&&typeof Pe.setStrictMode==`function`)try{Pe.setStrictMode(Ne,e)}catch{}}var Ie=Math.clz32?Math.clz32:ze,Le=Math.log,Re=Math.LN2;function ze(e){return e>>>=0,e===0?32:31-(Le(e)/Re|0)|0}var Be=256,Ve=262144,He=4194304;function Ue(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function We(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Ue(n))):i=Ue(o):i=Ue(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Ue(n))):i=Ue(o)):i=Ue(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Ge(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function Ke(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function qe(){var e=He;return He<<=1,!(He&62914560)&&(He=4194304),e}function Je(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function Ye(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Xe(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),an=!1;if(rn)try{var on={};Object.defineProperty(on,"passive",{get:function(){an=!0}}),window.addEventListener(`test`,on,on),window.removeEventListener(`test`,on,on)}catch{an=!1}var sn=null,cn=null,ln=null;function un(){if(ln)return ln;var e,t=cn,n=t.length,r,i=`value`in sn?sn.value:sn.textContent,a=i.length;for(e=0;e=Ln),Bn=` `,Vn=!1;function Hn(e,t){switch(e){case`keyup`:return Fn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Un(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Wn=!1;function lee(e,t){switch(e){case`compositionend`:return Un(t);case`keypress`:return t.which===32?(Vn=!0,Bn):null;case`textInput`:return e=t.data,e===Bn&&Vn?null:e;default:return null}}function Gn(e,t){if(Wn)return e===`compositionend`||!In&&Hn(e,t)?(e=un(),ln=cn=sn=null,Wn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=ur(n)}}function dr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?dr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function fr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Ft(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ft(e.document)}return t}function pr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var mr=rn&&`documentMode`in document&&11>=document.documentMode,hr=null,gr=null,_r=null,vr=!1;function yr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;vr||hr==null||hr!==Ft(r)||(r=hr,`selectionStart`in r&&pr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),_r&&lr(_r,r)||(_r=r,r=vd(gr,`onSelect`),0>=o,i-=o,di=1<<32-Ie(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),yi&&pi(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),yi&&pi(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return yi&&pi(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),yi&&pi(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===v&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case h:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===v){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===O&&ua(l)===r.type){n(e,r.sibling),c=a(r,o.props),_a(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===v?(c=Qr(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=Zr(o.type,o.key,o.props,null,e.mode,c),_a(c,o),c.return=e,e=c)}return s(e);case g:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=ti(o,e.mode,c),c.return=e,e=c}return s(e);case O:return o=ua(o),b(e,r,o,c)}if(F(o))return _(e,r,o,c);if(M(o)){if(l=M(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),y(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,ga(o),c);if(o.$$typeof===C)return b(e,r,Vi(e,o),c);va(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=$r(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{ha=0;var i=b(e,t,n,r);return ma=null,i}catch(t){if(t===ia||t===oa)throw t;var a=qr(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var ba=ya(!0),xa=ya(!1),Sa=!1;function Ca(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function wa(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Ta(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Ea(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Tl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=Wr(e),Ur(e,null,n),t}return Br(e,r,t,n),Wr(e)}function Da(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}function Oa(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var ka=!1;function Aa(){if(ka){var e=Zi;if(e!==null)throw e}}function ja(e,t,n,r){ka=!1;var i=e.updateQueue;Sa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,m=f!==s.lane;if(m?(Ol&f)===f:(r&f)===f){f!==0&&f===Xi&&(ka=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var h=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(h=g.payload,typeof h==`function`){d=h.call(_,d,f);break a}d=h;break a;case 3:h.flags=h.flags&-65537|128;case 0:if(h=g.payload,f=typeof h==`function`?h.call(_,d,f):h,f==null)break a;d=p({},d,f);break a;case 2:Sa=!0}}f=s.callback,f!==null&&(e.flags|=64,m&&(e.flags|=8192),m=i.callbacks,m===null?i.callbacks=[f]:m.push(f))}else m={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=m,c=d):u=u.next=m,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;m=s,s=m.next,m.next=null,i.lastBaseUpdate=m,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Il|=o,e.lanes=o,e.memoizedState=d}}function Ma(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Na(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=I.T,s={};I.T=s,ys(e,!1,t,n);try{var c=i(),l=I.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?vs(e,t,hee(c,r),iu(e)):vs(e,t,r,iu(e))}catch(n){vs(e,t,{then:function(){},status:`rejected`,reason:n},iu())}finally{L.p=a,o!==null&&s.types!==null&&(o.types=s.types),I.T=o}}function cs(){}function ls(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=us(e).queue;ss(e,a,t,R,n===null?cs:function(){return ds(e),n(r)})}function us(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:R,baseState:R,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:xo,lastRenderedState:R},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:xo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function ds(e){var t=us(e);t.next===null&&(t=e.alternate.memoizedState),vs(e,t.next.queue,{},iu())}function fs(){return Bi(Bf)}function ps(){return go().memoizedState}function ms(){return go().memoizedState}function hs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=iu();e=Ta(n);var r=Ea(t,e,n);r!==null&&(ou(r,t,n),Da(r,t,n)),t={cache:Ki()},e.payload=t;return}t=t.return}}function gs(e,t,n){var r=iu();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},bs(e)?xs(t,n):(n=Vr(e,t,n,r),n!==null&&(ou(n,e,r),Ss(n,t,r)))}function _s(e,t,n){vs(e,t,n,iu())}function vs(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(bs(e))xs(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,cr(s,o))return Br(e,t,i,0),El===null&&zr(),!1}catch{}if(n=Vr(e,t,i,r),n!==null)return ou(n,e,r),Ss(n,t,r),!0}return!1}function ys(e,t,n,r){if(r={lane:2,revertLane:nd(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},bs(e)){if(t)throw Error(i(479))}else t=Vr(e,n,r,2),t!==null&&ou(t,e,2)}function bs(e){var t=e.alternate;return e===Ya||t!==null&&t===Ya}function xs(e,t){$a=Qa=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Ss(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,Qe(e,n)}}var Cs={readContext:Bi,use:yo,useCallback:ao,useContext:ao,useEffect:ao,useImperativeHandle:ao,useLayoutEffect:ao,useInsertionEffect:ao,useMemo:ao,useReducer:ao,useRef:ao,useState:ao,useDebugValue:ao,useDeferredValue:ao,useTransition:ao,useSyncExternalStore:ao,useId:ao,useHostTransitionStatus:ao,useFormState:ao,useActionState:ao,useOptimistic:ao,useMemoCache:ao,useCacheRefresh:ao};Cs.useEffectEvent=ao;var ws={readContext:Bi,use:yo,useCallback:function(e,t){return ho().memoizedState=[e,t===void 0?null:t],e},useContext:Bi,useEffect:Yo,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),qo(4194308,4,es.bind(null,t,e),n)},useLayoutEffect:function(e,t){return qo(4194308,4,e,t)},useInsertionEffect:function(e,t){qo(4,2,e,t)},useMemo:function(e,t){var n=ho();t=t===void 0?null:t;var r=e();if(eo){Fe(!0);try{e()}finally{Fe(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=ho();if(n!==void 0){var i=n(t);if(eo){Fe(!0);try{n(t)}finally{Fe(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=gs.bind(null,Ya,e),[r.memoizedState,e]},useRef:function(e){var t=ho();return e={current:e},t.memoizedState=e},useState:function(e){e=jo(e);var t=e.queue,n=_s.bind(null,Ya,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:ns,useDeferredValue:function(e,t){return as(ho(),e,t)},useTransition:function(){var e=jo(!1);return e=ss.bind(null,Ya,e.queue,!0,!1),ho().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=Ya,a=ho();if(yi){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),El===null)throw Error(i(349));Ol&127||Eo(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,Yo(Oo.bind(null,r,o,e),[e]),r.flags|=2048,Go(9,{destroy:void 0},Do.bind(null,r,o,n,t),null),n},useId:function(){var e=ho(),t=El.identifierPrefix;if(yi){var n=fi,r=di;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=to++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[at]=t,o[ot]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Dd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&vc(t)}}return Cc(t),yc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&vc(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=oe.current,Ei(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=vi,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[at]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||wd(e.nodeValue,n)),e||Ci(t,!0)}else e=Nd(e).createTextNode(r),e[at]=t,t.stateNode=e}return Cc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ei(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[at]=t}else Di(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Cc(t),e=!1}else n=Oi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?(Ga(t),t):(Ga(t),null);if(t.flags&128)throw Error(i(558))}return Cc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ei(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[at]=t}else Di(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;Cc(t),a=!1}else a=Oi(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(Ga(t),t):(Ga(t),null)}return Ga(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),xc(t,t.updateQueue),Cc(t),null);case 4:return le(),e===null&&md(t.stateNode.containerInfo),Cc(t),null;case 10:return Pi(t.type),Cc(t),null;case 19:if(z(Ka),r=t.memoizedState,r===null)return Cc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Sc(r,!1);else{if(Fl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=qa(e),o!==null){for(t.flags|=128,Sc(r,!1),e=o.updateQueue,t.updateQueue=e,xc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)Xr(n,e),n=n.sibling;return re(Ka,Ka.current&1|2),yi&&pi(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&we()>Kl&&(t.flags|=128,a=!0,Sc(r,!1),t.lanes=4194304)}else{if(!a)if(e=qa(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,xc(t,e),Sc(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!yi)return Cc(t),null}else 2*we()-r.renderingStartTime>Kl&&n!==536870912&&(t.flags|=128,a=!0,Sc(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(Cc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=we(),e.sibling=null,n=Ka.current,re(Ka,a?n&1|2:n&1),yi&&pi(t,r.treeForkCount),e);case 22:case 23:return Ga(t),Ra(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(Cc(t),t.subtreeFlags&6&&(t.flags|=8192)):Cc(t),n=t.updateQueue,n!==null&&xc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&z(ea),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Pi(Gi),Cc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Tc(e,t){switch(gi(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Pi(Gi),le(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return de(t),null;case 31:if(t.memoizedState!==null){if(Ga(t),t.alternate===null)throw Error(i(340));Di()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if(Ga(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Di()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return z(Ka),null;case 4:return le(),null;case 10:return Pi(t.type),null;case 22:case 23:return Ga(t),Ra(),e!==null&&z(ea),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Pi(Gi),null;case 25:return null;default:return null}}function Ec(e,t){switch(gi(t),t.tag){case 3:Pi(Gi),le();break;case 26:case 27:case 5:de(t);break;case 4:le();break;case 31:t.memoizedState!==null&&Ga(t);break;case 13:Ga(t);break;case 19:z(Ka);break;case 10:Pi(t.type);break;case 22:case 23:Ga(t),Ra(),e!==null&&z(ea);break;case 24:Pi(Gi)}}function Dc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Fu(t,t.return,e)}}function Oc(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Fu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Fu(t,t.return,e)}}function kc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Na(t,n)}catch(t){Fu(e,e.return,t)}}}function Ac(e,t,n){n.props=js(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Fu(e,t,n)}}function jc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Fu(e,t,n)}}function Mc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Fu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Fu(e,t,n)}else n.current=null}function Nc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Fu(e,e.return,t)}}function Pc(e,t,n){try{var r=e.stateNode;Od(r,e.type,n,t),r[ot]=t}catch(t){Fu(e,e.return,t)}}function Fc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Wd(e.type)||e.tag===4}function Ic(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Fc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Wd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Lc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Jt));else if(r!==4&&(r===27&&Wd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Lc(e,t,n),e=e.sibling;e!==null;)Lc(e,t,n),e=e.sibling}function Rc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Wd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Rc(e,t,n),e=e.sibling;e!==null;)Rc(e,t,n),e=e.sibling}function H(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Dd(t,r,n),t[at]=e,t[ot]=n}catch(t){Fu(e,e.return,t)}}var zc=!1,Bc=!1,Vc=!1,Hc=typeof WeakSet==`function`?WeakSet:Set,Uc=null;function Wc(e,t){if(e=e.containerInfo,jd=Yf,e=fr(e),pr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(Md={focusedElem:e,selectionRange:n},Yf=!1,Uc=t;Uc!==null;)if(t=Uc,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Uc=e;else for(;Uc!==null;){switch(t=Uc,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Dd(o,r,n),o[at]=e,vt(o),r=o;break a;case`link`:var s=Af(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=B(s,h),v=B(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,I.T=null,n=eu,eu=null;var o=Xl,s=Ql;if(Yl=0,Zl=Xl=null,Ql=0,Tl&6)throw Error(i(331));var c=Tl;if(Tl|=4,bl(o.current),fl(o,o.current,s,n),Tl=c,Yu(0,!1),Pe&&typeof Pe.onPostCommitFiberRoot==`function`)try{Pe.onPostCommitFiberRoot(Ne,o)}catch{}return!0}finally{L.p=a,I.T=r,ju(e,t)}}function Pu(e,t,n){t=ri(n,t),t=Ls(e.stateNode,t,2),e=Ea(e,t,2),e!==null&&(Ye(e,2),Ju(e))}function Fu(e,t,n){if(e.tag===3)Pu(e,e,n);else for(;t!==null;){if(t.tag===3){Pu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(Jl===null||!Jl.has(r))){e=ri(n,e),n=Rs(2),r=Ea(t,n,2),r!==null&&(zs(n,r,t,e),Ye(r,2),Ju(r));break}}t=t.return}}function Iu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new wl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Nl=!0,i.add(n),e=Lu.bind(null,e,t,n),t.then(e,e))}function Lu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,El===e&&(Ol&n)===n&&(Fl===4||Fl===3&&(Ol&62914560)===Ol&&300>we()-Wl?!(Tl&2)&&fu(e,0):Rl|=n,Bl===Ol&&(Bl=0)),Ju(e)}function Ru(e,t){t===0&&(t=qe()),e=Hr(e,t),e!==null&&(Ye(e,t),Ju(e))}function zu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ru(e,n)}function Bu(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Ru(e,n)}function Vu(e,t){return be(e,t)}var Hu=null,Uu=null,Wu=!1,Gu=!1,Ku=!1,qu=0;function Ju(e){e!==Uu&&e.next===null&&(Uu===null?Hu=Uu=e:Uu=Uu.next=e),Gu=!0,Wu||(Wu=!0,td())}function Yu(e,t){if(!Ku&&Gu){Ku=!0;do for(var n=!1,r=Hu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ie(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,ed(r,a))}else a=Ol,a=We(r,r===El?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Ge(r,a)||(n=!0,ed(r,a));r=r.next}while(n);Ku=!1}}function Xu(){Zu()}function Zu(){Gu=Wu=!1;var e=0;qu!==0&&Rd()&&(e=qu);for(var t=we(),n=null,r=Hu;r!==null;){var i=r.next,a=Qu(r,t);a===0?(r.next=null,n===null?Hu=i:n.next=i,i===null&&(Uu=n)):(n=r,(e!==0||a&3)&&(Gu=!0)),r=i}Yl!==0&&Yl!==5||Yu(e,!1),qu!==0&&(qu=0)}function Qu(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&kd(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function uf(e,t,n){var r=lf;if(r&&typeof t==`string`&&t){var i=It(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),of.has(i)||(of.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Dd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function df(e){cf.D(e),uf(`dns-prefetch`,e,null)}function ff(e,t){cf.C(e,t),uf(`preconnect`,e,t)}function pf(e,t,n){cf.L(e,t,n);var r=lf;if(r&&e&&t){var i=`link[rel="preload"][as="`+It(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+It(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+It(n.imageSizes)+`"]`)):i+=`[href="`+It(e)+`"]`;var a=i;switch(t){case`style`:a=yf(e);break;case`script`:a=Cf(e)}af.has(a)||(e=p({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),af.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(bf(a))||t===`script`&&r.querySelector(wf(a))||(t=r.createElement(`link`),Dd(t,`link`,e),vt(t),r.head.appendChild(t)))}}function mf(e,t){cf.m(e,t);var n=lf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+It(r)+`"][href="`+It(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Cf(e)}if(!af.has(a)&&(e=p({rel:`modulepreload`,href:e},t),af.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(wf(a)))return}r=n.createElement(`link`),Dd(r,`link`,e),vt(r),n.head.appendChild(r)}}}function hf(e,t,n){cf.S(e,t,n);var r=lf;if(r&&e){var i=_t(r).hoistableStyles,a=yf(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(bf(a)))s.loading=5;else{e=p({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=af.get(a))&&Df(e,n);var c=o=r.createElement(`link`);vt(c),Dd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Ef(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function gf(e,t){cf.X(e,t);var n=lf;if(n&&e){var r=_t(n).hoistableScripts,i=Cf(e),a=r.get(i);a||(a=n.querySelector(wf(i)),a||(e=p({src:e,async:!0},t),(t=af.get(i))&&Of(e,t),a=n.createElement(`script`),vt(a),Dd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function _f(e,t){cf.M(e,t);var n=lf;if(n&&e){var r=_t(n).hoistableScripts,i=Cf(e),a=r.get(i);a||(a=n.querySelector(wf(i)),a||(e=p({src:e,async:!0,type:`module`},t),(t=af.get(i))&&Of(e,t),a=n.createElement(`script`),vt(a),Dd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function vf(e,t,n,r){var a=(a=oe.current)?sf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=yf(n.href),n=_t(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=yf(n.href);var o=_t(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(bf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),af.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},af.set(e,n),o||Sf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Cf(n),n=_t(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function yf(e){return`href="`+It(e)+`"`}function bf(e){return`link[rel="stylesheet"][`+e+`]`}function xf(e){return p({},e,{"data-precedence":e.precedence,precedence:null})}function Sf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Dd(t,`link`,n),vt(t),e.head.appendChild(t))}function Cf(e){return`[src="`+It(e)+`"]`}function wf(e){return`script[async]`+e}function Tf(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+It(n.href)+`"]`);if(r)return t.instance=r,vt(r),r;var a=p({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),vt(r),Dd(r,`style`,a),Ef(r,n.precedence,e),t.instance=r;case`stylesheet`:a=yf(n.href);var o=e.querySelector(bf(a));if(o)return t.state.loading|=4,t.instance=o,vt(o),o;r=xf(n),(a=af.get(a))&&Df(r,a),o=(e.ownerDocument||e).createElement(`link`),vt(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Dd(o,`link`,r),t.state.loading|=4,Ef(o,n.precedence,e),t.instance=o;case`script`:return o=Cf(n.src),(a=e.querySelector(wf(o)))?(t.instance=a,vt(a),a):(r=n,(a=af.get(o))&&(r=p({},n),Of(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),vt(a),Dd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Ef(r,n.precedence,e));return t.instance}function Ef(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Mf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Nf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Pf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=yf(r.href),a=t.querySelector(bf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=If.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,vt(a);return}a=t.ownerDocument||t,r=xf(r),(i=af.get(i))&&Df(r,i),a=a.createElement(`link`),vt(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Dd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=If.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Ff=0;function See(e,t){return e.stylesheets&&e.count===0&&Rf(e,e.stylesheets),0Ff?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function If(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Rf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Lf=null;function Rf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Lf=new Map,t.forEach(zf,e),Lf=null,If.call(e))}function zf(e,t){if(!(t.state.loading&4)){var n=Lf.get(e);if(n)var r=n.get(null);else{n=new Map,Lf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=b()})),S=e(a(),1),C=e(x()),w=`modulepreload`,T=function(e){return`/`+e},E={},D=function(e,t,n){let r=Promise.resolve();if(t&&t.length>0){let e=document.getElementsByTagName(`link`),i=document.querySelector(`meta[property=csp-nonce]`),a=i?.nonce||i?.getAttribute(`nonce`);function o(e){return Promise.all(e.map(e=>Promise.resolve(e).then(e=>({status:`fulfilled`,value:e}),e=>({status:`rejected`,reason:e}))))}r=o(t.map(t=>{if(t=T(t,n),t in E)return;E[t]=!0;let r=t.endsWith(`.css`),i=r?`[rel="stylesheet"]`:``;if(n)for(let n=e.length-1;n>=0;n--){let i=e[n];if(i.href===t&&(!r||i.rel===`stylesheet`))return}else if(document.querySelector(`link[href="${t}"]${i}`))return;let o=document.createElement(`link`);if(o.rel=r?`stylesheet`:w,r||(o.as=`script`),o.crossOrigin=``,o.href=t,a&&o.setAttribute(`nonce`,a),document.head.appendChild(o),r)return new Promise((e,n)=>{o.addEventListener(`load`,e),o.addEventListener(`error`,()=>n(Error(`Unable to preload CSS for ${t}`)))})}))}function i(e){let t=new Event(`vite:preloadError`,{cancelable:!0});if(t.payload=e,window.dispatchEvent(t),!t.defaultPrevented)throw e}return r.then(t=>{for(let e of t||[])e.status===`rejected`&&i(e.reason);return e().catch(i)})},O=`popstate`;function k(e){return typeof e==`object`&&!!e&&`pathname`in e&&`search`in e&&`hash`in e&&`state`in e&&`key`in e}function A(e={}){function t(e,t){let n=t.state?.masked,{pathname:r,search:i,hash:a}=n||e.location;return F(``,{pathname:r,search:i,hash:a},t.state&&t.state.usr||null,t.state&&t.state.key||`default`,n?{pathname:e.location.pathname,search:e.location.search,hash:e.location.hash}:void 0)}function n(e,t){return typeof t==`string`?t:I(t)}return R(t,n,null,e)}function j(e,t){if(e===!1||e==null)throw Error(t)}function M(e,t){if(!e){typeof console<`u`&&console.warn(t);try{throw Error(t)}catch{}}}function N(){return Math.random().toString(36).substring(2,10)}function P(e,t){return{usr:e.state,key:e.key,idx:t,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function F(e,t,n=null,r,i){return{pathname:typeof e==`string`?e:e.pathname,search:``,hash:``,...typeof t==`string`?L(t):t,state:n,key:t&&t.key||r||N(),mask:i}}function I({pathname:e=`/`,search:t=``,hash:n=``}){return t&&t!==`?`&&(e+=t.charAt(0)===`?`?t:`?`+t),n&&n!==`#`&&(e+=n.charAt(0)===`#`?n:`#`+n),e}function L(e){let t={};if(e){let n=e.indexOf(`#`);n>=0&&(t.hash=e.substring(n),e=e.substring(0,n));let r=e.indexOf(`?`);r>=0&&(t.search=e.substring(r),e=e.substring(0,r)),e&&(t.pathname=e)}return t}function R(e,t,n,r={}){let{window:i=document.defaultView,v5Compat:a=!1}=r,o=i.history,s=`POP`,c=null,l=u();l??(l=0,o.replaceState({...o.state,idx:l},``));function u(){return(o.state||{idx:null}).idx}function d(){s=`POP`;let e=u(),t=e==null?null:e-l;l=e,c&&c({action:s,location:h.location,delta:t})}function f(e,t){s=`PUSH`;let r=k(e)?e:F(h.location,e,t);n&&n(r,e),l=u()+1;let d=P(r,l),f=h.createHref(r.mask||r);try{o.pushState(d,``,f)}catch(e){if(e instanceof DOMException&&e.name===`DataCloneError`)throw e;i.location.assign(f)}a&&c&&c({action:s,location:h.location,delta:1})}function p(e,t){s=`REPLACE`;let r=k(e)?e:F(h.location,e,t);n&&n(r,e),l=u();let i=P(r,l),d=h.createHref(r.mask||r);o.replaceState(i,``,d),a&&c&&c({action:s,location:h.location,delta:0})}function m(e){return ee(i,e)}let h={get action(){return s},get location(){return e(i,o)},listen(e){if(c)throw Error(`A history only accepts one active listener`);return i.addEventListener(O,d),c=e,()=>{i.removeEventListener(O,d),c=null}},createHref(e){return t(i,e)},createURL:m,encodeLocation(e){let t=m(e);return{pathname:t.pathname,search:t.search,hash:t.hash}},push:f,replace:p,go(e){return o.go(e)}};return h}function ee(e,t,n=!1){let r=`http://localhost`;e&&(r=e.location.origin===`null`?e.location.href:e.location.origin),j(r,`No window.location.(origin|href) available to create URL`);let i=typeof t==`string`?t:I(t);return i=i.replace(/ $/,`%20`),!n&&i.startsWith(`//`)&&(i=r+i),new URL(i,r)}function te(e,t,n=`/`){return ne(e,t,n,!1)}function ne(e,t,n,r,i){let a=be((typeof t==`string`?L(t):t).pathname||`/`,n);if(a==null)return null;let o=i??re(e),s=null,c=ye(a);for(let e=0;s==null&&e{let c={relativePath:s===void 0?e.path||``:s,caseSensitive:e.caseSensitive===!0,childrenIndex:a,route:e};if(c.relativePath.startsWith(`/`)){if(!c.relativePath.startsWith(r)&&o)return;j(c.relativePath.startsWith(r),`Absolute route path "${c.relativePath}" nested under path "${r}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),c.relativePath=c.relativePath.slice(r.length)}let l=ke([r,c.relativePath]),u=n.concat(c);e.children&&e.children.length>0&&(j(e.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${l}".`),ie(e.children,t,u,l,o)),!(e.path==null&&!e.index)&&t.push({path:l,score:me(l,e.index),routesMeta:u})};return e.forEach((e,t)=>{if(e.path===``||!e.path?.includes(`?`))a(e,t);else for(let n of ae(e.path))a(e,t,!0,n)}),t}function ae(e){let t=e.split(`/`);if(t.length===0)return[];let[n,...r]=t,i=n.endsWith(`?`),a=n.replace(/\?$/,``);if(r.length===0)return i?[a,``]:[a];let o=ae(r.join(`/`)),s=[];return s.push(...o.map(e=>e===``?a:[a,e].join(`/`))),i&&s.push(...o),s.map(t=>e.startsWith(`/`)&&t===``?`/`:t)}function oe(e){e.sort((e,t)=>e.score===t.score?he(e.routesMeta.map(e=>e.childrenIndex),t.routesMeta.map(e=>e.childrenIndex)):t.score-e.score)}var se=/^:[\w-]+$/,ce=3,le=2,ue=1,de=10,fe=-2,pe=e=>e===`*`;function me(e,t){let n=e.split(`/`),r=n.length;return n.some(pe)&&(r+=fe),t&&(r+=le),n.filter(e=>!pe(e)).reduce((e,t)=>e+(se.test(t)?ce:t===``?ue:de),r)}function he(e,t){return e.length===t.length&&e.slice(0,-1).every((e,n)=>e===t[n])?e[e.length-1]-t[t.length-1]:0}function ge(e,t,n=!1){let{routesMeta:r}=e,i={},a=`/`,o=[];for(let e=0;e{if(t===`*`){let e=s[r]||``;o=a.slice(0,a.length-e.length).replace(/(.)\/+$/,`$1`)}let i=s[r];return n&&!i?e[t]=void 0:e[t]=(i||``).replace(/%2F/g,`/`),e},{}),pathname:a,pathnameBase:o,pattern:e}}function ve(e,t=!1,n=!0){M(e===`*`||!e.endsWith(`*`)||e.endsWith(`/*`),`Route path "${e}" will be treated as if it were "${e.replace(/\*$/,`/*`)}" because the \`*\` character must always follow a \`/\` in the pattern. To get rid of this warning, please change the route path to "${e.replace(/\*$/,`/*`)}".`);let r=[],i=`^`+e.replace(/\/*\*?$/,``).replace(/^\/*/,`/`).replace(/[\\.*+^${}|()[\]]/g,`\\$&`).replace(/\/:([\w-]+)(\?)?/g,(e,t,n,i,a)=>{if(r.push({paramName:t,isOptional:n!=null}),n){let t=a.charAt(i+e.length);return t&&t!==`/`?`/([^\\/]*)`:`(?:/([^\\/]*))?`}return`/([^\\/]+)`}).replace(/\/([\w-]+)\?(\/|$)/g,`(/$1)?$2`);return e.endsWith(`*`)?(r.push({paramName:`*`}),i+=e===`*`||e===`/*`?`(.*)$`:`(?:\\/(.+)|\\/*)$`):n?i+=`\\/*$`:e!==``&&e!==`/`&&(i+=`(?:(?=\\/|$))`),[new RegExp(i,t?void 0:`i`),r]}function ye(e){try{return e.split(`/`).map(e=>decodeURIComponent(e).replace(/\//g,`%2F`)).join(`/`)}catch(t){return M(!1,`The URL path "${e}" could not be decoded because it is a malformed URL segment. This is probably due to a bad percent encoding (${t}).`),e}}function be(e,t){if(t===`/`)return e;if(!e.toLowerCase().startsWith(t.toLowerCase()))return null;let n=t.endsWith(`/`)?t.length-1:t.length,r=e.charAt(n);return r&&r!==`/`?null:e.slice(n)||`/`}var xe=/^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;function Se(e,t=`/`){let{pathname:n,search:r=``,hash:i=``}=typeof e==`string`?L(e):e,a;return n?(n=Oe(n),a=n.startsWith(`/`)?Ce(n.substring(1),`/`):Ce(n,t)):a=t,{pathname:a,search:Me(r),hash:Ne(i)}}function Ce(e,t){let n=Ae(t).split(`/`);return e.split(`/`).forEach(e=>{e===`..`?n.length>1&&n.pop():e!==`.`&&n.push(e)}),n.length>1?n.join(`/`):`/`}function we(e,t,n,r){return`Cannot include a '${e}' character in a manually specified \`to.${t}\` field [${JSON.stringify(r)}]. Please separate it out to the \`to.${n}\` field. Alternatively you may provide the full path as a string in and the router will parse it for you.`}function Te(e){return e.filter((e,t)=>t===0||e.route.path&&e.route.path.length>0)}function Ee(e){let t=Te(e);return t.map((e,n)=>n===t.length-1?e.pathname:e.pathnameBase)}function De(e,t,n,r=!1){let i;typeof e==`string`?i=L(e):(i={...e},j(!i.pathname||!i.pathname.includes(`?`),we(`?`,`pathname`,`search`,i)),j(!i.pathname||!i.pathname.includes(`#`),we(`#`,`pathname`,`hash`,i)),j(!i.search||!i.search.includes(`#`),we(`#`,`search`,`hash`,i)));let a=e===``||i.pathname===``,o=a?`/`:i.pathname,s;if(o==null)s=n;else{let e=t.length-1;if(!r&&o.startsWith(`..`)){let t=o.split(`/`);for(;t[0]===`..`;)t.shift(),--e;i.pathname=t.join(`/`)}s=e>=0?t[e]:`/`}let c=Se(i,s),l=o&&o!==`/`&&o.endsWith(`/`),u=(a||o===`.`)&&n.endsWith(`/`);return!c.pathname.endsWith(`/`)&&(l||u)&&(c.pathname+=`/`),c}var Oe=e=>e.replace(/\/\/+/g,`/`),ke=e=>Oe(e.join(`/`)),Ae=e=>e.replace(/\/+$/,``),je=e=>Ae(e).replace(/^\/*/,`/`),Me=e=>!e||e===`?`?``:e.startsWith(`?`)?e:`?`+e,Ne=e=>!e||e===`#`?``:e.startsWith(`#`)?e:`#`+e,Pe=class{constructor(e,t,n,r=!1){this.status=e,this.statusText=t||``,this.internal=r,n instanceof Error?(this.data=n.toString(),this.error=n):this.data=n}};function Fe(e){return e!=null&&typeof e.status==`number`&&typeof e.statusText==`string`&&typeof e.internal==`boolean`&&`data`in e}function Ie(e){return ke(e.map(e=>e.route.path).filter(Boolean))||`/`}var Le=typeof window<`u`&&window.document!==void 0&&window.document.createElement!==void 0;function Re(e,t){let n=e;if(typeof n!=`string`||!xe.test(n))return{absoluteURL:void 0,isExternal:!1,to:n};let r=n,i=!1;if(Le)try{let e=new URL(window.location.href),r=n.startsWith(`//`)?new URL(e.protocol+n):new URL(n),a=be(r.pathname,t);r.origin===e.origin&&a!=null?n=a+r.search+r.hash:i=!0}catch{M(!1,` contains an invalid URL which will probably break when clicked - please update to a valid URL path.`)}return{absoluteURL:r,isExternal:i,to:n}}Object.getOwnPropertyNames(Object.prototype).sort().join(`\0`);var ze=[`POST`,`PUT`,`PATCH`,`DELETE`];new Set(ze);var Be=[`GET`,...ze];new Set(Be);var Ve=S.createContext(null);Ve.displayName=`DataRouter`;var He=S.createContext(null);He.displayName=`DataRouterState`;var Ue=S.createContext(!1);function We(){return S.useContext(Ue)}var Ge=S.createContext({isTransitioning:!1});Ge.displayName=`ViewTransition`;var Ke=S.createContext(new Map);Ke.displayName=`Fetchers`;var qe=S.createContext(null);qe.displayName=`Await`;var Je=S.createContext(null);Je.displayName=`Navigation`;var Ye=S.createContext(null);Ye.displayName=`Location`;var Xe=S.createContext({outlet:null,matches:[],isDataRoute:!1});Xe.displayName=`Route`;var Ze=S.createContext(null);Ze.displayName=`RouteError`;var Qe=`REACT_ROUTER_ERROR`,$e=`REDIRECT`,et=`ROUTE_ERROR_RESPONSE`;function tt(e){if(e.startsWith(`${Qe}:${$e}:{`))try{let t=JSON.parse(e.slice(28));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`&&typeof t.location==`string`&&typeof t.reloadDocument==`boolean`&&typeof t.replace==`boolean`)return t}catch{}}function nt(e){if(e.startsWith(`${Qe}:${et}:{`))try{let t=JSON.parse(e.slice(40));if(typeof t==`object`&&t&&typeof t.status==`number`&&typeof t.statusText==`string`)return new Pe(t.status,t.statusText,t.data)}catch{}}function rt(e,{relative:t}={}){j(it(),`useHref() may be used only in the context of a component.`);let{basename:n,navigator:r}=S.useContext(Je),{hash:i,pathname:a,search:o}=dt(e,{relative:t}),s=a;return n!==`/`&&(s=a===`/`?n:ke([n,a])),r.createHref({pathname:s,search:o,hash:i})}function it(){return S.useContext(Ye)!=null}function at(){return j(it(),`useLocation() may be used only in the context of a component.`),S.useContext(Ye).location}var ot=`You should call navigate() in a React.useEffect(), not when your component is first rendered.`;function st(e){S.useContext(Je).static||S.useLayoutEffect(e)}function ct(){let{isDataRoute:e}=S.useContext(Xe);return e?At():lt()}function lt(){j(it(),`useNavigate() may be used only in the context of a component.`);let e=S.useContext(Ve),{basename:t,navigator:n}=S.useContext(Je),{matches:r}=S.useContext(Xe),{pathname:i}=at(),a=JSON.stringify(Ee(r)),o=S.useRef(!1);return st(()=>{o.current=!0}),S.useCallback((r,s={})=>{if(M(o.current,ot),!o.current)return;if(typeof r==`number`){n.go(r);return}let c=De(r,JSON.parse(a),i,s.relative===`path`);e==null&&t!==`/`&&(c.pathname=c.pathname===`/`?t:ke([t,c.pathname])),(s.replace?n.replace:n.push)(c,s.state,s)},[t,n,a,i,e])}S.createContext(null);function ut(){let{matches:e}=S.useContext(Xe);return e[e.length-1]?.params??{}}function dt(e,{relative:t}={}){let{matches:n}=S.useContext(Xe),{pathname:r}=at(),i=JSON.stringify(Ee(n));return S.useMemo(()=>De(e,JSON.parse(i),r,t===`path`),[e,i,r,t])}function ft(e,t){return pt(e,t)}function pt(e,t,n){j(it(),`useRoutes() may be used only in the context of a component.`);let{navigator:r}=S.useContext(Je),{matches:i}=S.useContext(Xe),a=i[i.length-1],o=a?a.params:{},s=a?a.pathname:`/`,c=a?a.pathnameBase:`/`,l=a&&a.route;{let e=l&&l.path||``;Mt(s,!l||e.endsWith(`*`)||e.endsWith(`*?`),`You rendered descendant (or called \`useRoutes()\`) at "${s}" (under ) but the parent route path has no trailing "*". This means if you navigate deeper, the parent won't match anymore and therefore the child routes will never render. - -Please change the parent to .`)}let u=at(),d;if(t){let e=typeof t==`string`?L(t):t;j(c===`/`||e.pathname?.startsWith(c),`When overriding the location using \`\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${c}" but pathname "${e.pathname}" was given in the \`location\` prop.`),d=e}else d=u;let f=d.pathname||`/`,p=f;if(c!==`/`){let e=c.replace(/^\//,``).split(`/`);p=`/`+f.replace(/^\//,``).split(`/`).slice(e.length).join(`/`)}let m=n&&n.state.matches.length?n.state.matches.map(e=>Object.assign(e,{route:n.manifest[e.route.id]||e.route})):te(e,{pathname:p});M(l||m!=null,`No routes matched location "${d.pathname}${d.search}${d.hash}" `),M(m==null||m[m.length-1].route.element!==void 0||m[m.length-1].route.Component!==void 0||m[m.length-1].route.lazy!==void 0,`Matched leaf route at location "${d.pathname}${d.search}${d.hash}" does not have an element or Component. This means it will render an with a null value by default resulting in an "empty" page.`);let h=bt(m&&m.map(e=>Object.assign({},e,{params:Object.assign({},o,e.params),pathname:ke([c,r.encodeLocation?r.encodeLocation(e.pathname.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathname]),pathnameBase:e.pathnameBase===`/`?c:ke([c,r.encodeLocation?r.encodeLocation(e.pathnameBase.replace(/%/g,`%25`).replace(/\?/g,`%3F`).replace(/#/g,`%23`)).pathname:e.pathnameBase])})),i,n);return t&&h?S.createElement(Ye.Provider,{value:{location:{pathname:`/`,search:``,hash:``,state:null,key:`default`,mask:void 0,...d},navigationType:`POP`}},h):h}function mt(){let e=kt(),t=Fe(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),n=e instanceof Error?e.stack:null,r=`rgba(200,200,200, 0.5)`,i={padding:`0.5rem`,backgroundColor:r},a={padding:`2px 4px`,backgroundColor:r},o=null;return console.error(`Error handled by React Router default ErrorBoundary:`,e),o=S.createElement(S.Fragment,null,S.createElement(`p`,null,`💿 Hey developer 👋`),S.createElement(`p`,null,`You can provide a way better UX than this when your app throws errors by providing your own `,S.createElement(`code`,{style:a},`ErrorBoundary`),` or`,` `,S.createElement(`code`,{style:a},`errorElement`),` prop on your route.`)),S.createElement(S.Fragment,null,S.createElement(`h2`,null,`Unexpected Application Error!`),S.createElement(`h3`,{style:{fontStyle:`italic`}},t),n?S.createElement(`pre`,{style:i},n):null,o)}var ht=S.createElement(mt,null),gt=class extends S.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,t){return t.location!==e.location||t.revalidation!==`idle`&&e.revalidation===`idle`?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error===void 0?t.error:e.error,location:t.location,revalidation:e.revalidation||t.revalidation}}componentDidCatch(e,t){this.props.onError?this.props.onError(e,t):console.error(`React Router caught the following error during render`,e)}render(){let e=this.state.error;if(this.context&&typeof e==`object`&&e&&`digest`in e&&typeof e.digest==`string`){let t=nt(e.digest);t&&(e=t)}let t=e===void 0?this.props.children:S.createElement(Xe.Provider,{value:this.props.routeContext},S.createElement(Ze.Provider,{value:e,children:this.props.component}));return this.context?S.createElement(vt,{error:e},t):t}};gt.contextType=Ue;var _t=new WeakMap;function vt({children:e,error:t}){let{basename:n}=S.useContext(Je);if(typeof t==`object`&&t&&`digest`in t&&typeof t.digest==`string`){let e=tt(t.digest);if(e){let r=_t.get(t);if(r)throw r;let i=Re(e.location,n);if(Le&&!_t.get(t))if(i.isExternal||e.reloadDocument)window.location.href=i.absoluteURL||i.to;else{let n=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(i.to,{replace:e.replace}));throw _t.set(t,n),n}return S.createElement(`meta`,{httpEquiv:`refresh`,content:`0;url=${i.absoluteURL||i.to}`})}}return e}function yt({routeContext:e,match:t,children:n}){let r=S.useContext(Ve);return r&&r.static&&r.staticContext&&(t.route.errorElement||t.route.ErrorBoundary)&&(r.staticContext._deepestRenderedBoundaryId=t.route.id),S.createElement(Xe.Provider,{value:e},n)}function bt(e,t=[],n){let r=n?.state;if(e==null){if(!r)return null;if(r.errors)e=r.matches;else if(t.length===0&&!r.initialized&&r.matches.length>0)e=r.matches;else return null}let i=e,a=r?.errors;if(a!=null){let e=i.findIndex(e=>e.route.id&&a?.[e.route.id]!==void 0);j(e>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(a).join(`,`)}`),i=i.slice(0,Math.min(i.length,e+1))}let o=!1,s=-1;if(n&&r){o=r.renderFallback;for(let e=0;e=0?i.slice(0,s+1):[i[0]];break}}}}let c=n?.onError,l=r&&c?(e,t)=>{c(e,{location:r.location,params:r.matches?.[0]?.params??{},pattern:Ie(r.matches),errorInfo:t})}:void 0;return i.reduceRight((e,n,c)=>{let u,d=!1,f=null,p=null;r&&(u=a&&n.route.id?a[n.route.id]:void 0,f=n.route.errorElement||ht,o&&(s<0&&c===0?(Mt(`route-fallback`,!1,"No `HydrateFallback` element provided to render during initial hydration"),d=!0,p=null):s===c&&(d=!0,p=n.route.hydrateFallbackElement||null)));let m=t.concat(i.slice(0,c+1)),h=()=>{let t;return t=u?f:d?p:n.route.Component?S.createElement(n.route.Component,null):n.route.element?n.route.element:e,S.createElement(yt,{match:n,routeContext:{outlet:e,matches:m,isDataRoute:r!=null},children:t})};return r&&(n.route.ErrorBoundary||n.route.errorElement||c===0)?S.createElement(gt,{location:r.location,revalidation:r.revalidation,component:f,error:u,children:h(),routeContext:{outlet:null,matches:m,isDataRoute:!0},onError:l}):h()},null)}function xt(e){return`${e} must be used within a data router. See https://reactrouter.com/en/main/routers/picking-a-router.`}function St(e){let t=S.useContext(Ve);return j(t,xt(e)),t}function Ct(e){let t=S.useContext(He);return j(t,xt(e)),t}function wt(e){let t=S.useContext(Xe);return j(t,xt(e)),t}function Tt(e){let t=wt(e),n=t.matches[t.matches.length-1];return j(n.route.id,`${e} can only be used on routes that contain a unique "id"`),n.route.id}function Et(){return Tt(`useRouteId`)}function Dt(){let e=Ct(`useNavigation`);return S.useMemo(()=>{let{matches:t,historyAction:n,...r}=e.navigation;return r},[e.navigation])}function Ot(){let{matches:e,loaderData:t}=Ct(`useMatches`);return S.useMemo(()=>e.map(e=>z(e,t)),[e,t])}function kt(){let e=S.useContext(Ze),t=Ct(`useRouteError`),n=Tt(`useRouteError`);return e===void 0?t.errors?.[n]:e}function At(){let{router:e}=St(`useNavigate`),t=Tt(`useNavigate`),n=S.useRef(!1);return st(()=>{n.current=!0}),S.useCallback(async(r,i={})=>{M(n.current,ot),n.current&&(typeof r==`number`?await e.navigate(r):await e.navigate(r,{fromRouteId:t,...i}))},[e,t])}var jt={};function Mt(e,t,n){!t&&!jt[e]&&(jt[e]=!0,M(!1,n))}S.memo(Nt);function Nt({routes:e,manifest:t,future:n,state:r,isStatic:i,onError:a}){return pt(e,void 0,{manifest:t,state:r,isStatic:i,onError:a,future:n})}function Pt(e){j(!1,`A is only ever to be used as the child of element, never rendered directly. Please wrap your in a .`)}function Ft({basename:e=`/`,children:t=null,location:n,navigationType:r=`POP`,navigator:i,static:a=!1,useTransitions:o}){j(!it(),`You cannot render a inside another . You should never have more than one in your app.`);let s=e.replace(/^\/*/,`/`),c=S.useMemo(()=>({basename:s,navigator:i,static:a,useTransitions:o,future:{}}),[s,i,a,o]);typeof n==`string`&&(n=L(n));let{pathname:l=`/`,search:u=``,hash:d=``,state:f=null,key:p=`default`,mask:m}=n,h=S.useMemo(()=>{let e=be(l,s);return e==null?null:{location:{pathname:e,search:u,hash:d,state:f,key:p,mask:m},navigationType:r}},[s,l,u,d,f,p,r,m]);return M(h!=null,` is not able to match the URL "${l}${u}${d}" because it does not start with the basename, so the won't render anything.`),h==null?null:S.createElement(Je.Provider,{value:c},S.createElement(Ye.Provider,{children:t,value:h}))}function eee({children:e,location:t}){return ft(It(e),t)}S.Component;function It(e,t=[]){let n=[];return S.Children.forEach(e,(e,r)=>{if(!S.isValidElement(e))return;let i=[...t,r];if(e.type===S.Fragment){n.push.apply(n,It(e.props.children,i));return}j(e.type===Pt,`[${typeof e.type==`string`?e.type:e.type.name}] is not a component. All component children of must be a or `),j(!e.props.index||!e.props.children,`An index route cannot have child routes.`);let a={id:e.props.id||i.join(`-`),caseSensitive:e.props.caseSensitive,element:e.props.element,Component:e.props.Component,index:e.props.index,path:e.props.path,middleware:e.props.middleware,loader:e.props.loader,action:e.props.action,hydrateFallbackElement:e.props.hydrateFallbackElement,HydrateFallback:e.props.HydrateFallback,errorElement:e.props.errorElement,ErrorBoundary:e.props.ErrorBoundary,hasErrorBoundary:e.props.hasErrorBoundary===!0||e.props.ErrorBoundary!=null||e.props.errorElement!=null,shouldRevalidate:e.props.shouldRevalidate,handle:e.props.handle,lazy:e.props.lazy};e.props.children&&(a.children=It(e.props.children,i)),n.push(a)}),n}var Lt=`get`,Rt=`application/x-www-form-urlencoded`;function zt(e){return typeof HTMLElement<`u`&&e instanceof HTMLElement}function Bt(e){return zt(e)&&e.tagName.toLowerCase()===`button`}function Vt(e){return zt(e)&&e.tagName.toLowerCase()===`form`}function Ht(e){return zt(e)&&e.tagName.toLowerCase()===`input`}function Ut(e){return!!(e.metaKey||e.altKey||e.ctrlKey||e.shiftKey)}function tee(e,t){return e.button===0&&(!t||t===`_self`)&&!Ut(e)}function Wt(e=``){return new URLSearchParams(typeof e==`string`||Array.isArray(e)||e instanceof URLSearchParams?e:Object.keys(e).reduce((t,n)=>{let r=e[n];return t.concat(Array.isArray(r)?r.map(e=>[n,e]):[[n,r]])},[]))}function Gt(e,t){let n=Wt(e);return t&&t.forEach((e,r)=>{n.has(r)||t.getAll(r).forEach(e=>{n.append(r,e)})}),n}var Kt=null;function nee(){if(Kt===null)try{new FormData(document.createElement(`form`),0),Kt=!1}catch{Kt=!0}return Kt}var ree=new Set([`application/x-www-form-urlencoded`,`multipart/form-data`,`text/plain`]);function qt(e){return e!=null&&!ree.has(e)?(M(!1,`"${e}" is not a valid \`encType\` for \`
\`/\`\` and will default to "${Rt}"`),null):e}function Jt(e,t){let n,r,i,a,o;if(Vt(e)){let o=e.getAttribute(`action`);r=o?be(o,t):null,n=e.getAttribute(`method`)||Lt,i=qt(e.getAttribute(`enctype`))||Rt,a=new FormData(e)}else if(Bt(e)||Ht(e)&&(e.type===`submit`||e.type===`image`)){let o=e.form;if(o==null)throw Error(`Cannot submit a + + + + {error && ( + + {error} + + )} + + + + + + + + Username + Email + Role + Active + Actions + + + {(item) => ( + + {item.username} + {item.email || "—"} + {item.global_role} + {item.is_active ? "Yes" : "No"} + + + + + + + {(close) => ( + + Delete User + + + Delete user {item.username}? This + cannot be undone. + + + + + + + )} + + + + + )} + + + + + + ); +} + +function CreateUserDialog({ onCreated }: { onCreated: () => void }) { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"admin" | "user">("user"); + const [error, setError] = useState(null); + + const submit = async (close: () => void) => { + setError(null); + try { + const res = await apiFetch(`${BACKEND_API_URL}/users`, { + method: "POST", + body: JSON.stringify({ username, password, email, global_role: role }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.detail ?? "Failed to create user"); + } + setUsername(""); + setPassword(""); + setEmail(""); + setRole("user"); + close(); + onCreated(); + ToastQueue.positive("User created", { timeout: 2000 }); + } catch (e) { + setError(e instanceof Error ? e.message : "Error"); + } + }; + + return ( + + + {(close) => ( + + Create User + + + {error && ( + + {error} + + )} + + + + + setRole(k as "admin" | "user")} + > + User + Admin + + + + + + + + + )} + + ); +} + +function ChangeRoleDialog({ + user, + onChanged, +}: { + user: UserRow; + onChanged: () => void; +}) { + const [role, setRole] = useState<"admin" | "user">(user.global_role); + + const save = async (close: () => void) => { + await apiFetch(`${BACKEND_API_URL}/users/${user.id}`, { + method: "PUT", + body: JSON.stringify({ global_role: role }), + }); + close(); + onChanged(); + ToastQueue.positive("Role updated", { timeout: 2000 }); + }; + + return ( + + + {(close) => ( + + Edit {user.username} + + + setRole(k as "admin" | "user")} + > + User + Admin + + + + + + + + )} + + ); +} diff --git a/toktagger/ui/src/app/pages/login.tsx b/toktagger/ui/src/app/pages/login.tsx new file mode 100644 index 000000000..45ec8023b --- /dev/null +++ b/toktagger/ui/src/app/pages/login.tsx @@ -0,0 +1,104 @@ +"use client"; +import { useState } from "react"; +import { Navigate } from "react-router-dom"; +import { Heading, InlineAlert, TextField, Button } from "@adobe/react-spectrum"; +import { useAuth } from "@/app/contexts/AuthContext"; + +export default function LoginPage() { + const { login, isLoading, user } = useAuth(); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + if (!isLoading && user) { + return ; + } + + const handleLogin = async () => { + if (!username || !password) { + setError("Invalid username or password"); + return; + } + setError(null); + setSubmitting(true); + try { + await login(username, password); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setSubmitting(false); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + handleLogin(); + }; + + return ( +
+
+ + TokTagger — Sign In + + {error && ( + + {error} + + )} + + + +
+
+ ); +} diff --git a/toktagger/ui/src/app/pages/profile.tsx b/toktagger/ui/src/app/pages/profile.tsx new file mode 100644 index 000000000..b01c2cf54 --- /dev/null +++ b/toktagger/ui/src/app/pages/profile.tsx @@ -0,0 +1,154 @@ +"use client"; +import { useState } from "react"; +import { + Breadcrumbs, + Item, + Divider, + TextField, + Button, + Flex, + ToastQueue, +} from "@adobe/react-spectrum"; +import { BACKEND_API_URL, apiFetch } from "@/app/core"; +import { useAuth } from "@/app/contexts/AuthContext"; + +export default function ProfilePage() { + const { user } = useAuth(); + + const [email, setEmail] = useState(user?.email ?? ""); + const [emailSaving, setEmailSaving] = useState(false); + + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordSaving, setPasswordSaving] = useState(false); + + const saveEmail = async () => { + if (!user) return; + setEmailSaving(true); + try { + const res = await apiFetch(`${BACKEND_API_URL}/users/${user._id}`, { + method: "PUT", + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.detail ?? "Failed to save email"); + } + ToastQueue.positive("Email updated", { timeout: 2000 }); + } catch (e) { + ToastQueue.negative(e instanceof Error ? e.message : "Error", { + timeout: 3000, + }); + } finally { + setEmailSaving(false); + } + }; + + const savePassword = async () => { + if (!user) return; + if (newPassword !== confirmPassword) { + ToastQueue.negative("Passwords do not match", { timeout: 3000 }); + return; + } + if (newPassword.length < 8) { + ToastQueue.negative("Password must be at least 8 characters", { + timeout: 3000, + }); + return; + } + setPasswordSaving(true); + try { + const res = await apiFetch(`${BACKEND_API_URL}/users/${user._id}`, { + method: "PUT", + body: JSON.stringify({ password: newPassword }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.detail ?? "Failed to change password"); + } + ToastQueue.positive("Password changed", { timeout: 2000 }); + setNewPassword(""); + setConfirmPassword(""); + } catch (e) { + ToastQueue.negative(e instanceof Error ? e.message : "Error", { + timeout: 3000, + }); + } finally { + setPasswordSaving(false); + } + }; + + return ( +
+ + + Projects + + Profile + +
+
+

Profile

+ + +

+ Username: {user?.username} +   |   + Role: {user?.global_role} +

+ + + + + + +

Change Password

+ + + +
+
+
+
+
+ ); +} diff --git a/toktagger/ui/src/app/projects/components/project_config.tsx b/toktagger/ui/src/app/projects/components/project_config.tsx index ad2764295..9b4b82c53 100644 --- a/toktagger/ui/src/app/projects/components/project_config.tsx +++ b/toktagger/ui/src/app/projects/components/project_config.tsx @@ -25,7 +25,7 @@ import { import AddCircle from "@spectrum-icons/workflow/AddCircle"; import Edit from "@spectrum-icons/workflow/EditCircle"; import { useState, useEffect } from "react"; -import { BACKEND_API_URL } from "@/app/core"; +import { BACKEND_API_URL, apiFetch } from "@/app/core"; import { useAPISchema } from "@/app/contexts/apiSchema"; import { SchemaParser } from "@/schemaParser"; @@ -174,7 +174,7 @@ export function ProjectConfigEditor({ useEffect(() => { async function fetchDataLoaders() { try { - const response = await fetch(`${BACKEND_API_URL}/meta/dataloader`); + const response = await apiFetch(`${BACKEND_API_URL}/meta/dataloader`); if (response.ok) { const dataLoadersList = await response.json(); const loaders = dataLoadersList.map((item: string) => ({ @@ -242,7 +242,7 @@ export function ProjectConfigEditor({ } // Create project via API - const response = await fetch(url, { + const response = await apiFetch(url, { method: method, headers: { "Content-Type": "application/json", diff --git a/toktagger/ui/src/app/projects/page.tsx b/toktagger/ui/src/app/projects/page.tsx index 18cdf8818..8bbbb60bd 100644 --- a/toktagger/ui/src/app/projects/page.tsx +++ b/toktagger/ui/src/app/projects/page.tsx @@ -3,10 +3,9 @@ import { useState, useEffect, useCallback } from "react"; import { deleteProject, getProjects } from "@/app/core"; import Delete from "@spectrum-icons/workflow/Delete"; import { ProjectConfigEditor } from "./components/project_config"; -import { useNavigate, useHref } from "react-router-dom"; +import { useNavigate } from "react-router-dom"; +import { useAuth } from "@/app/contexts/AuthContext"; import { - Provider, - defaultTheme, Cell, Column, Row, @@ -19,7 +18,6 @@ import { Picker, Flex, SearchField, - ToastContainer, DialogTrigger, Dialog, Divider, @@ -37,112 +35,94 @@ type ProjectsTableProps = { onModify?: () => void; }; -const ProjectsBreadCrumbs = () => { - return ( - - - - Projects - - - - ); -}; - const ProjectsTable = ({ projects, sortDescriptor, onSortChange, onModify, }: ProjectsTableProps) => { - const navigate = useNavigate(); - const rows = projects.map(({ _id, ...rest }) => ({ - ...rest, - id: _id, - _id: _id, - })); + const rows = projects.map(({ _id, ...rest }) => ({ ...rest, id: _id, _id })); return ( - - - - - - Name - - - Task - - - Date Created - - - Loader - - Actions - - - {(item) => ( - - {item["name"]} - {item["task"]} - {item["timestamp"]} - {item["data_loader"]} - - - - - - {(close) => ( - - Confirm Deletion - - - Are you sure you want to delete project{" "} - {item["name"]}? You will also lose{" "} - all annotations associated with - this project. This action cannot be undone. - - - - - - - )} - - - - - )} - - - - + + + + + Name + + + Task + + + Date Created + + + Loader + + Actions + + + {(item) => ( + + {item["name"]} + {item["task"]} + {item["timestamp"]} + {item["data_loader"]} + + + + + + {(close) => ( + + Confirm Deletion + + + Are you sure you want to delete project{" "} + {item["name"]}? You will also lose{" "} + all annotations associated with this + project. This action cannot be undone. + + + + + + + )} + + + + + )} + + + ); }; export default function Projects() { + const { user, logout } = useAuth(); + const navigate = useNavigate(); const [projectsPerPage, setProjectsPerPage] = useState(10); const [currentPage, setCurrentPage] = useState(1); const [projectName, setProjectName] = useState(""); @@ -153,101 +133,114 @@ export default function Projects() { const [projects, setProjects] = useState([]); const refreshProjects = useCallback(async () => { - const projects = await getProjects( - sortDescriptor, - currentPage, - projectsPerPage, - projectName, + setProjects( + await getProjects( + sortDescriptor, + currentPage, + projectsPerPage, + projectName, + ), ); - - setProjects(projects); - }, [sortDescriptor, currentPage, projectsPerPage, projectName, setProjects]); + }, [sortDescriptor, currentPage, projectsPerPage, projectName]); useEffect(() => { refreshProjects(); - }, [ - sortDescriptor, - currentPage, - projectsPerPage, - projectName, - refreshProjects, - ]); - - if (!projects) { - return; - } + }, [refreshProjects]); - const onSortChange = (newSortDescriptor: SortDescriptor) => { - setSortDescriptor(newSortDescriptor); - }; + if (!projects) return; return (
- -
-
-

Projects

- - - - - { - if (name != null) { - setProjectName(name); - setCurrentPage(1); - } - }} - /> - - -
+ + + Projects + + +
+
+
+

Projects

+
+ + Signed in as {user?.username} + -
-

Page: {currentPage}

- { - if (selectedKey != null) { - setProjectsPerPage(Number(selectedKey) || 10); - setCurrentPage(1); - } - }} - defaultSelectedKey="10" + {user?.global_role === "admin" && ( +
- + )} +
- +
+ + + { + if (name != null) { + setProjectName(name); + setCurrentPage(1); + } + }} + /> + + setSortDescriptor(d)} + onModify={refreshProjects} + /> +
+ +
+

Page: {currentPage}

+ { + if (k != null) { + setProjectsPerPage(Number(k) || 10); + setCurrentPage(1); + } + }} + defaultSelectedKey="10" + > + 5 + 10 + 25 + 50 + +
+ +
diff --git a/toktagger/ui/src/app/projects/project_id/components/add_samples.tsx b/toktagger/ui/src/app/projects/project_id/components/add_samples.tsx index fe2b0be88..71325396c 100644 --- a/toktagger/ui/src/app/projects/project_id/components/add_samples.tsx +++ b/toktagger/ui/src/app/projects/project_id/components/add_samples.tsx @@ -27,7 +27,7 @@ import { } from "@/types"; import AddCircle from "@spectrum-icons/workflow/AddCircle"; import { useState, useEffect } from "react"; -import { BACKEND_API_URL } from "@/app/core"; +import { BACKEND_API_URL, apiFetch } from "@/app/core"; import NumericalRange, { NumericalRangeType, } from "@/app/components/ui/numerical_range"; @@ -69,7 +69,7 @@ export const AddSamplesEditor = ({ useEffect(() => { async function fetchDataSchema() { try { - const response = await fetch( + const response = await apiFetch( `${BACKEND_API_URL}/meta/dataloader/${dataLoader}`, ); if (response.ok) { @@ -171,7 +171,7 @@ export const AddSamplesEditor = ({ if (useDirectories) { apiUrl = `${BACKEND_API_URL}/paths/directories?dir_path=${dirPath}&file_type=${fileType}`; } - const response = await fetch(apiUrl); + const response = await apiFetch(apiUrl); if (response.ok) { const result = await response.json(); @@ -290,7 +290,7 @@ export const AddSamplesEditor = ({ }); // POST to API - const response = await fetch( + const response = await apiFetch( `${BACKEND_API_URL}/projects/${project._id}/samples`, { method: "POST", diff --git a/toktagger/ui/src/app/projects/project_id/components/members.tsx b/toktagger/ui/src/app/projects/project_id/components/members.tsx new file mode 100644 index 000000000..a266bb252 --- /dev/null +++ b/toktagger/ui/src/app/projects/project_id/components/members.tsx @@ -0,0 +1,210 @@ +"use client"; +import { useState, useEffect, useCallback } from "react"; +import { + Button, + DialogTrigger, + Dialog, + Heading, + Divider, + Content, + ButtonGroup, + TableView, + TableHeader, + TableBody, + Column, + Row, + Cell, + Flex, + TextField, + Picker, + Item, + InlineAlert, + ToastQueue, +} from "@adobe/react-spectrum"; +import { BACKEND_API_URL, apiFetch } from "@/app/core"; +import type { ProjectMember } from "@/types"; + +interface Props { + projectId: string; + isProjectAdmin: boolean; +} + +export function ProjectMembersDialog({ projectId, isProjectAdmin }: Props) { + const [members, setMembers] = useState([]); + const [open, setOpen] = useState(false); + + const refresh = useCallback(async () => { + const res = await apiFetch( + `${BACKEND_API_URL}/projects/${projectId}/members`, + ); + if (res.ok) { + setMembers(await res.json()); + } + }, [projectId]); + + useEffect(() => { + if (open) refresh(); + }, [open, refresh]); + + const removeMember = async (userId: string) => { + await apiFetch( + `${BACKEND_API_URL}/projects/${projectId}/members/${userId}`, + { method: "DELETE" }, + ); + await refresh(); + ToastQueue.positive("Member removed", { timeout: 2000 }); + }; + + const updateRole = async (userId: string, role: string) => { + await apiFetch( + `${BACKEND_API_URL}/projects/${projectId}/members/${userId}`, + { + method: "PUT", + body: JSON.stringify({ role }), + }, + ); + await refresh(); + ToastQueue.positive("Role updated", { timeout: 2000 }); + }; + + return ( + + + + Project Members + + + {isProjectAdmin && ( + + )} + + + Username + Role + {isProjectAdmin ? ( + Actions + ) : ( + + )} + + + {(item) => ( + + {item.username} + + {isProjectAdmin ? ( + + updateRole(item.user_id, k as string) + } + width="size-1600" + > + Admin + Annotator + Viewer + + ) : ( + item.role + )} + + + {isProjectAdmin ? ( + + ) : ( + "" + )} + + + )} + + + + + + + + + ); +} + +function AddMemberForm({ + projectId, + onAdded, +}: { + projectId: string; + onAdded: () => void; +}) { + const [username, setUsername] = useState(""); + const [role, setRole] = useState("annotator"); + const [error, setError] = useState(null); + + const add = async () => { + setError(null); + try { + const res = await apiFetch( + `${BACKEND_API_URL}/projects/${projectId}/members`, + { + method: "POST", + body: JSON.stringify({ username, role }), + }, + ); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + throw new Error(d?.detail ?? "Failed to add member"); + } + setUsername(""); + setRole("annotator"); + onAdded(); + ToastQueue.positive("Member added", { timeout: 2000 }); + } catch (e) { + setError(e instanceof Error ? e.message : "Error"); + } + }; + + return ( + + {error && ( + + {error} + + )} + + setRole(k as string)} + width="size-1600" + > + Admin + Annotator + Viewer + + + + ); +} diff --git a/toktagger/ui/src/app/projects/project_id/page.tsx b/toktagger/ui/src/app/projects/project_id/page.tsx index 3726b666b..d484dec38 100644 --- a/toktagger/ui/src/app/projects/project_id/page.tsx +++ b/toktagger/ui/src/app/projects/project_id/page.tsx @@ -1,8 +1,6 @@ "use client"; import { useEffect, useState, useCallback } from "react"; import { - Provider, - defaultTheme, Cell, Column, Row, @@ -15,7 +13,6 @@ import { Button, Picker, SearchField, - ToastContainer, DialogTrigger, Dialog, Heading, @@ -27,9 +24,13 @@ import { ContextualHelp, Footer, Link, + Provider, + defaultTheme, + ToastContainer, } from "@adobe/react-spectrum"; import { SortDescriptor } from "@react-types/shared"; import { AddSamplesEditor } from "./components/add_samples"; +import { ProjectMembersDialog } from "./components/members"; import { getSamples, getProject, @@ -45,7 +46,9 @@ import { useHref, useNavigate, useParams } from "react-router-dom"; import { ImportButton } from "@/app/components/tools/import"; import { ExportButton } from "@/app/components/tools/export"; import { JumpToNextButton } from "@/app/components/tools/nav"; +import { useAuth } from "@/app/contexts/AuthContext"; import { useServerHealth } from "@/app/contexts/healthContext"; + const SampleBreadCrumbs = ({ project }: { project: Project }) => { const navigate = useNavigate(); return ( @@ -77,98 +80,96 @@ const SamplesTable = ({ onSortChange, onModify, }: SamplesTableProps) => { - const navigate = useNavigate(); const rows = samples.map(({ _id, ...rest }) => ({ ...rest, id: _id, })); return ( - - - - - - Shot ID - - - Date Created - - - Validated - - Actions - - - {(item) => ( - - {item["shot_id"]} - {item["timestamp"]} - - - - - - - - {(close) => ( - - Confirm Deletion - - - Are you sure you want to delete sample with Shot ID{" "} - {item["shot_id"]}? You will also - lose all annotations associated - with this sample. This action cannot be undone. - - - - - - - )} - - - - - )} - - - - + + + + + Shot ID + + + Date Created + + + Validated + + Actions + + + {(item) => ( + + {item["shot_id"]} + {item["timestamp"]} + + + + + + + + {(close) => ( + + Confirm Deletion + + + Are you sure you want to delete sample with Shot ID{" "} + {item["shot_id"]}? You will also lose{" "} + all annotations associated with this + sample. This action cannot be undone. + + + + + + + )} + + + + + )} + + + ); }; export default function ProjectView() { const { project_id } = useParams(); + const { user: currentUser } = useAuth(); const hasId = project_id !== undefined; const [samplesPerPage, setSamplesPerPage] = useState(10); @@ -255,8 +256,8 @@ export default function ProjectView() { return (
-
-
+
+

Samples

+ {project_id && ( + + )}