From 2b9f8c34052ae64468abd6e2455a8bc1df2e776e Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 17:15:31 +0200 Subject: [PATCH 1/3] feat(cli): export the OpenAPI schema and keep the frontend snapshot in step nevus openapi prints the schema of a throwaway application, and frontend/openapi.json holds the committed copy. CI regenerates the snapshot and the typed client and fails when either is stale; just openapi refreshes both. The generated files are excluded from Prettier so the comparison is byte for byte. Signed-off-by: Jose David --- .github/workflows/ci.yml | 8 + backend/src/nevus/cli/main.py | 17 + frontend/.prettierignore | 3 + frontend/openapi.json | 1867 +++++++++++++++++++++++++++++++++ justfile | 5 + 5 files changed, 1900 insertions(+) create mode 100644 frontend/openapi.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 66f0224..54b849a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,10 @@ jobs: env: NEVUS_DATABASE_URL: postgresql+psycopg://nevus:nevus@localhost:5432/nevus run: uv run pytest + - name: OpenAPI snapshot is current + run: | + uv run nevus openapi > /tmp/openapi.json + diff -q /tmp/openapi.json ../frontend/openapi.json || { echo "frontend/openapi.json is stale: run 'just openapi'"; exit 1; } - name: Migrations on SQLite env: NEVUS_DATA_DIR: ${{ runner.temp }}/sqlite @@ -91,6 +95,10 @@ jobs: cache: pnpm cache-dependency-path: frontend/pnpm-lock.yaml - run: pnpm install --frozen-lockfile + - name: Generated API types are current + run: | + pnpm openapi + git diff --exit-code -- src/lib/api/schema.d.ts || { echo "src/lib/api/schema.d.ts is stale: run 'just openapi'"; exit 1; } - run: pnpm typecheck - run: pnpm lint - run: pnpm format:check diff --git a/backend/src/nevus/cli/main.py b/backend/src/nevus/cli/main.py index 63d8ea4..5df6c66 100644 --- a/backend/src/nevus/cli/main.py +++ b/backend/src/nevus/cli/main.py @@ -13,12 +13,29 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--version", action="version", version=f"nevus {__version__}") sub = parser.add_subparsers(dest="command", required=True) sub.add_parser("serve", help="run the web application") + sub.add_parser("openapi", help="print the OpenAPI schema as JSON") args = parser.parse_args(argv) if args.command == "serve": return _serve() + if args.command == "openapi": + return _openapi() return 2 +def _openapi() -> int: + import json + import tempfile + from pathlib import Path + + from nevus.app import create_app + from nevus.config import Settings + + with tempfile.TemporaryDirectory() as tmp: + app = create_app(Settings(data_dir=Path(tmp), log_level="warning")) + print(json.dumps(app.openapi(), indent=2, sort_keys=True)) + return 0 + + def _serve() -> int: import uvicorn diff --git a/frontend/.prettierignore b/frontend/.prettierignore index 5350426..c24fbfd 100644 --- a/frontend/.prettierignore +++ b/frontend/.prettierignore @@ -2,3 +2,6 @@ dist dev-dist node_modules pnpm-lock.yaml +# Generated from the backend schema; kept byte-identical to the generator output so CI can diff it. +openapi.json +src/lib/api/schema.d.ts diff --git a/frontend/openapi.json b/frontend/openapi.json new file mode 100644 index 0000000..a7bdfcf --- /dev/null +++ b/frontend/openapi.json @@ -0,0 +1,1867 @@ +{ + "components": { + "schemas": { + "AccessIn": { + "properties": { + "role": { + "enum": [ + "manager", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "username": { + "maxLength": 64, + "minLength": 2, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "role" + ], + "title": "AccessIn", + "type": "object" + }, + "AccessOut": { + "properties": { + "role": { + "enum": [ + "owner", + "manager", + "viewer" + ], + "title": "Role", + "type": "string" + }, + "user_id": { + "format": "uuid", + "title": "User Id", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "user_id", + "username", + "role" + ], + "title": "AccessOut", + "type": "object" + }, + "Body_upload_image_api_persons__person_id__images_post": { + "properties": { + "captured_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captured At" + }, + "captured_tz": { + "anyOf": [ + { + "maxLength": 64, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captured Tz" + }, + "file": { + "contentMediaType": "application/octet-stream", + "title": "File", + "type": "string" + }, + "modality": { + "default": "camera", + "enum": [ + "camera", + "dermatoscope" + ], + "title": "Modality", + "type": "string" + }, + "role": { + "default": "close_up", + "enum": [ + "overview", + "close_up", + "with_reference", + "other" + ], + "title": "Role", + "type": "string" + } + }, + "required": [ + "file" + ], + "title": "Body_upload_image_api_persons__person_id__images_post", + "type": "object" + }, + "Credentials": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 10, + "title": "Password", + "type": "string" + }, + "username": { + "maxLength": 64, + "minLength": 2, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "Credentials", + "type": "object" + }, + "HTTPValidationError": { + "properties": { + "detail": { + "items": { + "$ref": "#/components/schemas/ValidationError" + }, + "title": "Detail", + "type": "array" + } + }, + "title": "HTTPValidationError", + "type": "object" + }, + "Health": { + "properties": { + "database": { + "enum": [ + "ok", + "unavailable" + ], + "title": "Database", + "type": "string" + }, + "status": { + "enum": [ + "ok", + "degraded" + ], + "title": "Status", + "type": "string" + }, + "storage": { + "enum": [ + "ok", + "unavailable" + ], + "title": "Storage", + "type": "string" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "status", + "version", + "database", + "storage" + ], + "title": "Health", + "type": "object" + }, + "ImageOut": { + "properties": { + "bytes": { + "title": "Bytes", + "type": "integer" + }, + "captured_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Captured At" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "height": { + "title": "Height", + "type": "integer" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "mime": { + "title": "Mime", + "type": "string" + }, + "modality": { + "title": "Modality", + "type": "string" + }, + "orientation": { + "title": "Orientation", + "type": "integer" + }, + "person_id": { + "format": "uuid", + "title": "Person Id", + "type": "string" + }, + "re_encoded": { + "title": "Re Encoded", + "type": "boolean" + }, + "renditions": { + "items": { + "$ref": "#/components/schemas/RenditionOut" + }, + "title": "Renditions", + "type": "array" + }, + "role": { + "title": "Role", + "type": "string" + }, + "sha256": { + "title": "Sha256", + "type": "string" + }, + "source_format": { + "title": "Source Format", + "type": "string" + }, + "width": { + "title": "Width", + "type": "integer" + } + }, + "required": [ + "id", + "person_id", + "role", + "modality", + "sha256", + "bytes", + "mime", + "width", + "height", + "orientation", + "source_format", + "re_encoded", + "captured_at", + "created_at", + "renditions" + ], + "title": "ImageOut", + "type": "object" + }, + "InstanceStatus": { + "properties": { + "claimed": { + "title": "Claimed", + "type": "boolean" + }, + "version": { + "title": "Version", + "type": "string" + } + }, + "required": [ + "claimed", + "version" + ], + "title": "InstanceStatus", + "type": "object" + }, + "PasswordChange": { + "properties": { + "current_password": { + "maxLength": 1024, + "minLength": 1, + "title": "Current Password", + "type": "string" + }, + "new_password": { + "maxLength": 1024, + "minLength": 10, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "current_password", + "new_password" + ], + "title": "PasswordChange", + "type": "object" + }, + "PasswordReset": { + "properties": { + "new_password": { + "maxLength": 1024, + "minLength": 10, + "title": "New Password", + "type": "string" + } + }, + "required": [ + "new_password" + ], + "title": "PasswordReset", + "type": "object" + }, + "PersonIn": { + "properties": { + "birth_year": { + "anyOf": [ + { + "maximum": 2100.0, + "minimum": 1900.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Birth Year" + }, + "display_name": { + "maxLength": 120, + "minLength": 1, + "title": "Display Name", + "type": "string" + }, + "skin_tone": { + "anyOf": [ + { + "maxLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Skin Tone" + } + }, + "required": [ + "display_name" + ], + "title": "PersonIn", + "type": "object" + }, + "PersonOut": { + "properties": { + "birth_year": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Birth Year" + }, + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "display_name": { + "title": "Display Name", + "type": "string" + }, + "experimental_analysis": { + "title": "Experimental Analysis", + "type": "boolean" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "my_role": { + "enum": [ + "owner", + "manager", + "viewer" + ], + "title": "My Role", + "type": "string" + }, + "owner_user_id": { + "format": "uuid", + "title": "Owner User Id", + "type": "string" + }, + "skin_tone": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Skin Tone" + }, + "updated_at": { + "format": "date-time", + "title": "Updated At", + "type": "string" + } + }, + "required": [ + "id", + "display_name", + "birth_year", + "skin_tone", + "owner_user_id", + "experimental_analysis", + "created_at", + "updated_at", + "my_role" + ], + "title": "PersonOut", + "type": "object" + }, + "PersonUpdate": { + "properties": { + "birth_year": { + "anyOf": [ + { + "maximum": 2100.0, + "minimum": 1900.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Birth Year" + }, + "display_name": { + "anyOf": [ + { + "maxLength": 120, + "minLength": 1, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Display Name" + }, + "experimental_analysis": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Experimental Analysis" + }, + "skin_tone": { + "anyOf": [ + { + "maxLength": 16, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Skin Tone" + } + }, + "title": "PersonUpdate", + "type": "object" + }, + "RenditionOut": { + "properties": { + "bytes": { + "title": "Bytes", + "type": "integer" + }, + "height": { + "title": "Height", + "type": "integer" + }, + "kind": { + "title": "Kind", + "type": "string" + }, + "width": { + "title": "Width", + "type": "integer" + } + }, + "required": [ + "kind", + "width", + "height", + "bytes" + ], + "title": "RenditionOut", + "type": "object" + }, + "SessionOut": { + "properties": { + "instance_claimed": { + "default": true, + "title": "Instance Claimed", + "type": "boolean" + }, + "sudo_until": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Sudo Until" + }, + "user": { + "$ref": "#/components/schemas/UserOut" + } + }, + "required": [ + "user", + "sudo_until" + ], + "title": "SessionOut", + "type": "object" + }, + "SudoIn": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 1, + "title": "Password", + "type": "string" + } + }, + "required": [ + "password" + ], + "title": "SudoIn", + "type": "object" + }, + "UserCreate": { + "properties": { + "email": { + "anyOf": [ + { + "maxLength": 254, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "password": { + "maxLength": 1024, + "minLength": 10, + "title": "Password", + "type": "string" + }, + "role": { + "default": "member", + "enum": [ + "admin", + "member" + ], + "title": "Role", + "type": "string" + }, + "username": { + "maxLength": 64, + "minLength": 2, + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$", + "title": "Username", + "type": "string" + } + }, + "required": [ + "username", + "password" + ], + "title": "UserCreate", + "type": "object" + }, + "UserOut": { + "properties": { + "created_at": { + "format": "date-time", + "title": "Created At", + "type": "string" + }, + "disabled_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Disabled At" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "id": { + "format": "uuid", + "title": "Id", + "type": "string" + }, + "language": { + "title": "Language", + "type": "string" + }, + "last_login_at": { + "anyOf": [ + { + "format": "date-time", + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Login At" + }, + "role": { + "enum": [ + "admin", + "member" + ], + "title": "Role", + "type": "string" + }, + "show_uncertainty": { + "title": "Show Uncertainty", + "type": "boolean" + }, + "theme": { + "title": "Theme", + "type": "string" + }, + "username": { + "title": "Username", + "type": "string" + } + }, + "required": [ + "id", + "username", + "email", + "role", + "language", + "theme", + "show_uncertainty", + "created_at", + "last_login_at", + "disabled_at" + ], + "title": "UserOut", + "type": "object" + }, + "UserUpdateMe": { + "properties": { + "email": { + "anyOf": [ + { + "maxLength": 254, + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Email" + }, + "language": { + "anyOf": [ + { + "enum": [ + "en", + "es" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Language" + }, + "show_uncertainty": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Show Uncertainty" + }, + "theme": { + "anyOf": [ + { + "enum": [ + "system", + "light", + "dark" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Theme" + } + }, + "title": "UserUpdateMe", + "type": "object" + }, + "ValidationError": { + "properties": { + "ctx": { + "title": "Context", + "type": "object" + }, + "input": { + "title": "Input" + }, + "loc": { + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ] + }, + "title": "Location", + "type": "array" + }, + "msg": { + "title": "Message", + "type": "string" + }, + "type": { + "title": "Error Type", + "type": "string" + } + }, + "required": [ + "loc", + "msg", + "type" + ], + "title": "ValidationError", + "type": "object" + } + } + }, + "info": { + "description": "Personal documentation and measurement aid for skin marks. Not a diagnostic tool.", + "title": "neVus", + "version": "0.0.0" + }, + "openapi": "3.1.0", + "paths": { + "/api/auth/claim": { + "post": { + "description": "The first person in claims the instance and becomes its administrator. Refused once anybody exists.", + "operationId": "claim_api_auth_claim_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Credentials" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Claim", + "tags": [ + "auth" + ] + } + }, + "/api/auth/instance": { + "get": { + "operationId": "instance_status_api_auth_instance_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstanceStatus" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Instance Status", + "tags": [ + "auth" + ] + } + }, + "/api/auth/login": { + "post": { + "operationId": "login_api_auth_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Credentials" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Login", + "tags": [ + "auth" + ] + } + }, + "/api/auth/logout": { + "post": { + "operationId": "logout_api_auth_logout_post", + "responses": { + "204": { + "description": "Successful Response" + } + }, + "summary": "Logout", + "tags": [ + "auth" + ] + } + }, + "/api/auth/me": { + "patch": { + "operationId": "update_me_api_auth_me_patch", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserUpdateMe" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Me", + "tags": [ + "auth" + ] + } + }, + "/api/auth/me/password": { + "post": { + "operationId": "change_password_api_auth_me_password_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordChange" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Change Password", + "tags": [ + "auth" + ] + } + }, + "/api/auth/session": { + "get": { + "operationId": "session_info_api_auth_session_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionOut" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Session Info", + "tags": [ + "auth" + ] + } + }, + "/api/auth/sudo": { + "post": { + "description": "Re-authenticate for a few minutes before destructive actions.", + "operationId": "sudo_api_auth_sudo_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SudoIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SessionOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Sudo", + "tags": [ + "auth" + ] + } + }, + "/api/images/{image_id}": { + "delete": { + "description": "Moves the image to the trash; the purge and the garbage collection of blobs arrive with data management.", + "operationId": "delete_image_api_images__image_id__delete", + "parameters": [ + { + "in": "path", + "name": "image_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Image Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Image", + "tags": [ + "images" + ] + }, + "get": { + "operationId": "get_image_api_images__image_id__get", + "parameters": [ + { + "in": "path", + "name": "image_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Image Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Image", + "tags": [ + "images" + ] + } + }, + "/api/images/{image_id}/{kind}": { + "get": { + "description": "The bytes, only for people who may see the person; cacheable forever because the content is addressed by hash.", + "operationId": "image_file_api_images__image_id___kind__get", + "parameters": [ + { + "in": "path", + "name": "image_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Image Id", + "type": "string" + } + }, + { + "in": "path", + "name": "kind", + "required": true, + "schema": { + "enum": [ + "original", + "full", + "preview", + "thumb" + ], + "title": "Kind", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Image File", + "tags": [ + "images" + ] + } + }, + "/api/persons": { + "get": { + "operationId": "list_persons_api_persons_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/PersonOut" + }, + "title": "Response List Persons Api Persons Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Persons", + "tags": [ + "persons" + ] + }, + "post": { + "operationId": "create_person_api_persons_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonIn" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create Person", + "tags": [ + "persons" + ] + } + }, + "/api/persons/{person_id}": { + "delete": { + "description": "Soft delete (30-day trash); the purge arrives with the data-management features. Needs sudo mode.", + "operationId": "delete_person_api_persons__person_id__delete", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Delete Person", + "tags": [ + "persons" + ] + }, + "get": { + "operationId": "get_person_api_persons__person_id__get", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Get Person", + "tags": [ + "persons" + ] + }, + "patch": { + "operationId": "update_person_api_persons__person_id__patch", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PersonOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Update Person", + "tags": [ + "persons" + ] + } + }, + "/api/persons/{person_id}/access": { + "get": { + "operationId": "list_access_api_persons__person_id__access_get", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/AccessOut" + }, + "title": "Response List Access Api Persons Person Id Access Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Access", + "tags": [ + "persons" + ] + }, + "put": { + "operationId": "grant_access_api_persons__person_id__access_put", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessIn" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccessOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Grant Access", + "tags": [ + "persons" + ] + } + }, + "/api/persons/{person_id}/access/{user_id}": { + "delete": { + "operationId": "revoke_access_api_persons__person_id__access__user_id__delete", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Revoke Access", + "tags": [ + "persons" + ] + } + }, + "/api/persons/{person_id}/images": { + "get": { + "operationId": "list_images_api_persons__person_id__images_get", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ImageOut" + }, + "title": "Response List Images Api Persons Person Id Images Get", + "type": "array" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "List Images", + "tags": [ + "images" + ] + }, + "post": { + "operationId": "upload_image_api_persons__person_id__images_post", + "parameters": [ + { + "in": "path", + "name": "person_id", + "required": true, + "schema": { + "format": "uuid", + "title": "Person Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/Body_upload_image_api_persons__person_id__images_post" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Upload Image", + "tags": [ + "images" + ] + } + }, + "/api/users": { + "get": { + "operationId": "list_users_api_users_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/UserOut" + }, + "title": "Response List Users Api Users Get", + "type": "array" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "List Users", + "tags": [ + "users" + ] + }, + "post": { + "operationId": "create_user_api_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserCreate" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Create User", + "tags": [ + "users" + ] + } + }, + "/api/users/{user_id}/disable": { + "post": { + "operationId": "disable_user_api_users__user_id__disable_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Disable User", + "tags": [ + "users" + ] + } + }, + "/api/users/{user_id}/enable": { + "post": { + "operationId": "enable_user_api_users__user_id__enable_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UserOut" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Enable User", + "tags": [ + "users" + ] + } + }, + "/api/users/{user_id}/password": { + "post": { + "operationId": "reset_password_api_users__user_id__password_post", + "parameters": [ + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "format": "uuid", + "title": "User Id", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordReset" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Reset Password", + "tags": [ + "users" + ] + } + }, + "/healthz": { + "get": { + "operationId": "healthz_healthz_get", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Health" + } + } + }, + "description": "Successful Response" + } + }, + "summary": "Healthz", + "tags": [ + "system" + ] + } + } + } +} diff --git a/justfile b/justfile index 8637879..487cdeb 100644 --- a/justfile +++ b/justfile @@ -34,6 +34,11 @@ image engine="podman" version="devel": run engine="podman" version="devel": {{engine}} run --rm -p 8080:8080 -v "$PWD/data:/data:Z" -e PUID=$(id -u) -e PGID=$(id -g) nevus:{{version}} +# Regenerate the OpenAPI snapshot and the frontend's typed client from it. +openapi: + cd backend && uv run nevus openapi > ../frontend/openapi.json + cd frontend && pnpm openapi + # Database migrations. migrate: cd backend && uv run alembic upgrade head From 15ab54b857e71bf4418d627aa11ff75b97386451 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 17:15:31 +0200 Subject: [PATCH 2/3] fix(config): read NEVUS_ALLOWED_HOSTS as a plain comma-separated list pydantic-settings decodes list-typed variables as JSON before validators run, so the documented form nevus.example.com crashed the container at start-up. The field is now marked NoDecode and split on commas, with a test that goes through the environment. Signed-off-by: Jose David --- backend/src/nevus/config.py | 9 ++++++--- backend/tests/test_config.py | 8 ++++++++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/src/nevus/config.py b/backend/src/nevus/config.py index 6f2acf1..e8ba6b6 100644 --- a/backend/src/nevus/config.py +++ b/backend/src/nevus/config.py @@ -5,10 +5,10 @@ import secrets from functools import lru_cache from pathlib import Path -from typing import Literal +from typing import Annotated, Literal from pydantic import Field, field_validator -from pydantic_settings import BaseSettings, SettingsConfigDict +from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict class Settings(BaseSettings): @@ -19,7 +19,10 @@ class Settings(BaseSettings): data_dir: Path = Field(default=Path("data"), description="Holds the database, blobs, backups and secrets") database_url: str | None = Field(default=None, description="SQLAlchemy URL; defaults to SQLite inside data_dir") static_dir: Path | None = Field(default=None, description="Built frontend; a placeholder is served when missing") - allowed_hosts: list[str] = Field(default_factory=list, description="Hostnames answered beyond private addresses") + # NoDecode keeps pydantic-settings from parsing the variable as JSON, so a plain "a.example, b.example" works. + allowed_hosts: Annotated[list[str], NoDecode] = Field( + default_factory=list, description="Hostnames answered beyond private addresses; comma-separated" + ) secret_key_file: Path | None = Field(default=None, description="Server secret file; generated when missing") log_level: Literal["debug", "info", "warning", "error"] = "info" role: Literal["all", "web", "worker"] = "all" diff --git a/backend/tests/test_config.py b/backend/tests/test_config.py index e161b71..e7c883b 100644 --- a/backend/tests/test_config.py +++ b/backend/tests/test_config.py @@ -34,3 +34,11 @@ def test_secret_key_is_generated_once_with_restrictive_permissions(tmp_path: Pat assert first == second assert len(first) == 64 assert oct((tmp_path / "secret.key").stat().st_mode & 0o777) == "0o600" + + +def test_allowed_hosts_read_from_the_environment_as_a_plain_list( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("NEVUS_ALLOWED_HOSTS", "nevus.example.com, Photos.Example.ORG ,") + settings = Settings(data_dir=tmp_path) + assert settings.allowed_hosts == ["nevus.example.com", "photos.example.org"] From 60349aa8f39f94f5e927d86bd5a46482832efd07 Mon Sep 17 00:00:00 2001 From: Jose David Date: Wed, 23 Sep 2026 17:15:31 +0200 Subject: [PATCH 3/3] feat(frontend): claim, sign in, persons, camera upload and settings First usable interface for phase 1: the claim page for a fresh instance, sign-in, the persons list with creation, a person page that takes a photo with the device camera (or picks a file) and shows the scrubbed thumbnails with a preview dialog, and settings for language, theme and uncertainty display stored with the account. Requests go through a typed client generated from the OpenAPI snapshot; redirects are derived from session state. Strings in English and Spanish; five component tests run against an in-memory API mock. Signed-off-by: Jose David --- frontend/package.json | 9 +- frontend/pnpm-lock.yaml | 924 ++++++----- frontend/src/app/App.module.css | 46 - frontend/src/app/App.tsx | 58 +- frontend/src/app/Shell.module.css | 45 + frontend/src/app/Shell.tsx | 28 + .../components/Button.module.css | 37 + .../src/design-system/components/Button.tsx | 14 + .../design-system/components/Card.module.css | 7 + .../src/design-system/components/Card.tsx | 6 + .../components/EmptyState.module.css | 12 + .../design-system/components/EmptyState.tsx | 13 + .../components/Notice.module.css | 24 + .../src/design-system/components/Notice.tsx | 17 + .../components/TextField.module.css | 33 + .../design-system/components/TextField.tsx | 35 + frontend/src/features/auth/ClaimPage.tsx | 25 + .../src/features/auth/CredentialsForm.tsx | 87 + frontend/src/features/auth/LoginPage.tsx | 22 + frontend/src/features/auth/RequireAuth.tsx | 13 + frontend/src/features/auth/auth.module.css | 12 + frontend/src/features/persons/HomePage.tsx | 72 + frontend/src/features/persons/PersonPage.tsx | 154 ++ .../src/features/persons/persons.module.css | 129 ++ .../src/features/settings/SettingsPage.tsx | 73 + .../src/features/settings/settings.module.css | 37 + frontend/src/lib/api/client.ts | 31 + frontend/src/lib/api/schema.d.ts | 1464 +++++++++++++++++ frontend/src/lib/auth/session.ts | 103 ++ frontend/src/lib/i18n/locales/en.json | 58 +- frontend/src/lib/i18n/locales/es.json | 58 +- frontend/src/lib/images/index.ts | 60 + frontend/src/lib/persons/index.ts | 44 + frontend/src/lib/theme/index.ts | 8 + frontend/src/main.tsx | 4 +- frontend/tests/app.test.tsx | 96 +- frontend/tests/mockApi.ts | 112 ++ 37 files changed, 3505 insertions(+), 465 deletions(-) delete mode 100644 frontend/src/app/App.module.css create mode 100644 frontend/src/app/Shell.module.css create mode 100644 frontend/src/app/Shell.tsx create mode 100644 frontend/src/design-system/components/Button.module.css create mode 100644 frontend/src/design-system/components/Button.tsx create mode 100644 frontend/src/design-system/components/Card.module.css create mode 100644 frontend/src/design-system/components/Card.tsx create mode 100644 frontend/src/design-system/components/EmptyState.module.css create mode 100644 frontend/src/design-system/components/EmptyState.tsx create mode 100644 frontend/src/design-system/components/Notice.module.css create mode 100644 frontend/src/design-system/components/Notice.tsx create mode 100644 frontend/src/design-system/components/TextField.module.css create mode 100644 frontend/src/design-system/components/TextField.tsx create mode 100644 frontend/src/features/auth/ClaimPage.tsx create mode 100644 frontend/src/features/auth/CredentialsForm.tsx create mode 100644 frontend/src/features/auth/LoginPage.tsx create mode 100644 frontend/src/features/auth/RequireAuth.tsx create mode 100644 frontend/src/features/auth/auth.module.css create mode 100644 frontend/src/features/persons/HomePage.tsx create mode 100644 frontend/src/features/persons/PersonPage.tsx create mode 100644 frontend/src/features/persons/persons.module.css create mode 100644 frontend/src/features/settings/SettingsPage.tsx create mode 100644 frontend/src/features/settings/settings.module.css create mode 100644 frontend/src/lib/api/client.ts create mode 100644 frontend/src/lib/api/schema.d.ts create mode 100644 frontend/src/lib/auth/session.ts create mode 100644 frontend/src/lib/images/index.ts create mode 100644 frontend/src/lib/persons/index.ts create mode 100644 frontend/src/lib/theme/index.ts create mode 100644 frontend/tests/mockApi.ts diff --git a/frontend/package.json b/frontend/package.json index 8f31c80..341955a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,20 +13,24 @@ "format": "prettier --write .", "format:check": "prettier --check .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "openapi": "openapi-typescript openapi.json -o src/lib/api/schema.d.ts" }, "dependencies": { "@tanstack/react-query": "^5.90.0", "i18next": "^25.5.0", "i18next-browser-languagedetector": "^8.2.0", + "openapi-fetch": "^0.14.0", "react": "^19.2.0", "react-dom": "^19.2.0", - "react-i18next": "^16.0.0" + "react-i18next": "^16.0.0", + "react-router": "^7.9.0" }, "devDependencies": { "@eslint/js": "^9.36.0", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.0", "@types/node": "^24.5.0", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", @@ -36,6 +40,7 @@ "eslint-plugin-react-hooks": "^6.1.0", "globals": "^16.4.0", "jsdom": "^27.0.0", + "openapi-typescript": "^7.9.0", "prettier": "^3.6.0", "stylelint": "^16.24.0", "stylelint-config-standard": "^39.0.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index b9f0808..e8d8064 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -175,6 +175,9 @@ importers: i18next-browser-languagedetector: specifier: ^8.2.0 version: 8.2.1 + openapi-fetch: + specifier: ^0.14.0 + version: 0.14.1 react: specifier: ^19.2.0 version: 19.3.0 @@ -184,6 +187,9 @@ importers: react-i18next: specifier: ^16.0.0 version: 16.6.6(i18next@25.10.10(typescript@5.9.3))(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(typescript@5.9.3) + react-router: + specifier: ^7.9.0 + version: 7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0) devDependencies: '@eslint/js': specifier: ^9.36.0 @@ -194,6 +200,9 @@ importers: '@testing-library/react': specifier: ^16.3.0 version: 16.3.3(@testing-library/dom@10.4.2)(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + '@testing-library/user-event': + specifier: ^14.6.0 + version: 14.6.7(@testing-library/dom@10.4.2) '@types/node': specifier: ^24.5.0 version: 24.13.6 @@ -205,46 +214,49 @@ importers: version: 19.3.0(@types/react@19.3.0) '@vitejs/plugin-react': specifier: ^5.0.0 - version: 5.2.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2)) + version: 5.2.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2)) eslint: specifier: ^9.36.0 - version: 9.39.5(supports-color@7.2.0) + version: 9.39.5(supports-color@10.2.2) eslint-plugin-jsx-a11y: specifier: ^6.10.0 - version: 6.10.2(eslint@9.39.5(supports-color@7.2.0)) + version: 6.10.2(eslint@9.39.5(supports-color@10.2.2)) eslint-plugin-react-hooks: specifier: ^6.1.0 - version: 6.1.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0) + version: 6.1.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2) globals: specifier: ^16.4.0 version: 16.5.0 jsdom: specifier: ^27.0.0 - version: 27.4.0(supports-color@7.2.0) + version: 27.4.0(supports-color@10.2.2) + openapi-typescript: + specifier: ^7.9.0 + version: 7.13.0(typescript@5.9.3) prettier: specifier: ^3.6.0 version: 3.9.8 stylelint: specifier: ^16.24.0 - version: 16.26.1(supports-color@7.2.0)(typescript@5.9.3) + version: 16.26.1(supports-color@10.2.2)(typescript@5.9.3) stylelint-config-standard: specifier: ^39.0.0 - version: 39.0.1(stylelint@16.26.1(supports-color@7.2.0)(typescript@5.9.3)) + version: 39.0.1(stylelint@16.26.1(supports-color@10.2.2)(typescript@5.9.3)) typescript: specifier: ^5.9.0 version: 5.9.3 typescript-eslint: specifier: ^8.44.0 - version: 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + version: 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) vite: specifier: ^7.1.0 version: 7.3.6(@types/node@24.13.6)(terser@5.51.2) vite-plugin-pwa: specifier: ^1.0.0 - version: 1.3.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))(workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@7.2.0))(workbox-window@7.4.1) + version: 1.3.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))(workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@10.2.2))(workbox-window@7.4.1) vitest: specifier: ^3.2.0 - version: 3.2.7(@types/node@24.13.6)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0)(terser@5.51.2) + version: 3.2.7(@types/node@24.13.6)(jsdom@27.4.0(supports-color@10.2.2))(supports-color@10.2.2)(terser@5.51.2) packages: @@ -1124,6 +1136,16 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@redocly/ajv@8.11.2': + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + + '@redocly/config@0.22.0': + resolution: {integrity: sha512-gAy93Ddo01Z3bHuVdPWfCwzgfaYgMdaZPcfL7JZ7hWJoK9V0lXDbigTWkhiPFAaLWzbOJ+kbUQG1+XwIm0KRGQ==} + + '@redocly/openapi-core@1.34.20': + resolution: {integrity: sha512-ypeBZ/6BKXR9+7/TtbKhbl4UgD7raHhPS12oknlKno2A8+lnFkxIwiE/Aklu6L2cd/ioH+fCWuMxi9/p3EyAPw==} + engines: {node: '>=18.17.0', npm: '>=9.5.0'} + '@rolldown/pluginutils@1.0.0-rc.3': resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} @@ -1348,6 +1370,12 @@ packages: '@types/react-dom': optional: true + '@testing-library/user-event@14.6.7': + resolution: {integrity: sha512-MPCpX8bxe8zS+JmmTwLp8jd0dy1rAm60Te/SL8JrQM3qvQJcBOs1d7IefJMyZzqM3EWBrDn/LWDt1BCGu4ASfg==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': resolution: {integrity: sha512-/67zpWDBLV+oYAEL682s1ktXL0HgqX76f6gaVGkGnVZlBbm1zd0v4Bz8MFF2GGhoX9rvfq3KSQHubFHwa6w6/Q==} engines: {node: '>=12'} @@ -1510,6 +1538,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -1679,6 +1711,9 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -1693,6 +1728,9 @@ packages: colord@2.10.0: resolution: {integrity: sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==} + colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -1706,6 +1744,10 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-js-compat@3.50.0: resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} engines: {node: '>=6.4.0'} @@ -2212,6 +2254,10 @@ packages: resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} engines: {node: '>=8'} + index-to-position@1.2.0: + resolution: {integrity: sha512-Yg7+ztRkqslMAS2iFaU+Oa4KTSidr63OsFGlOrJoW981kIYO3CGCS3wA95P1mUi/IVSJkn0D479KTJpVpvFNuw==} + engines: {node: '>=18'} + ini@1.3.8: resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} @@ -2367,6 +2413,10 @@ packages: engines: {node: '>=10'} hasBin: true + js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2563,6 +2613,18 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + + openapi-typescript@7.13.0: + resolution: {integrity: sha512-EFP392gcqXS7ntPvbhBzbF8TyBA+baIYEm791Hy5YkjDYKTnk/Tn5OQeKm5BIZvJihpp8Zzr4hzx0Irde1LNGQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2590,6 +2652,10 @@ packages: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-json@8.3.0: + resolution: {integrity: sha512-ybiGyvspI+fAoRQbIPRddCcSTV9/LsJbf0e/S85VLowVGzRmokfneg2kwVW/KU5rOXrPSbF1qAKPMgNTqqROQQ==} + engines: {node: '>=18'} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -2630,6 +2696,10 @@ packages: resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} engines: {node: '>=12'} + pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -2714,6 +2784,16 @@ packages: resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} engines: {node: '>=0.10.0'} + react-router@7.18.4: + resolution: {integrity: sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} @@ -2809,6 +2889,9 @@ packages: resolution: {integrity: sha512-k3CMsaIvvdSwm8oLB4MXSl0wH2/cwlH7xGcnRd2DaeRmBkbzYmyT8j0tsX60DwD1eRwHTpNpH8ljKu9oUT1MeQ==} engines: {node: '>=20.0.0'} + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -2953,6 +3036,10 @@ packages: engines: {node: '>=18.12.0'} hasBin: true + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -3043,6 +3130,10 @@ packages: resolution: {integrity: sha512-eaBzG6MxNzEn9kiwvtre90cXaNLkmadMWa1zQMs3XORCXNbsH/OewwbxC5ia9dCxIxnTAsSxXJaa/p5y8DlvJg==} engines: {node: '>=10'} + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + typed-array-buffer@1.0.3: resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} engines: {node: '>= 0.4'} @@ -3112,6 +3203,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -3341,6 +3435,13 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -3392,20 +3493,20 @@ snapshots: '@babel/compat-data@7.29.7': {} - '@babel/core@7.29.7(supports-color@7.2.0)': + '@babel/core@7.29.7(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helpers': 7.29.7 '@babel/parser': 7.29.9 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -3432,32 +3533,32 @@ snapshots: lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/helper-create-regexp-features-plugin@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 regexpu-core: 6.4.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-define-polyfill-provider@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) lodash.debounce: 4.0.8 resolve: 1.22.12 transitivePeerDependencies: @@ -3465,26 +3566,26 @@ snapshots: '@babel/helper-globals@7.29.7': {} - '@babel/helper-member-expression-to-functions@7.29.7(supports-color@7.2.0)': + '@babel/helper-member-expression-to-functions@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-imports@7.29.7(supports-color@7.2.0)': + '@babel/helper-module-imports@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -3494,27 +3595,27 @@ snapshots: '@babel/helper-plugin-utils@7.29.7': {} - '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-remap-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-wrap-function': 7.29.7(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/helper-wrap-function': 7.29.7(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-member-expression-to-functions': 7.29.7(supports-color@10.2.2) '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@7.2.0)': + '@babel/helper-skip-transparent-expression-wrappers@7.29.7(supports-color@10.2.2)': dependencies: - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3525,10 +3626,10 @@ snapshots: '@babel/helper-validator-option@7.29.7': {} - '@babel/helper-wrap-function@7.29.7(supports-color@7.2.0)': + '@babel/helper-wrap-function@7.29.7(supports-color@10.2.2)': dependencies: '@babel/template': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -3542,492 +3643,492 @@ snapshots: dependencies: '@babel/types': 7.29.8 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-bugfix-safari-class-field-initializer-scope@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) - '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-import-assertions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-import-attributes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-arrow-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-async-generator-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-async-to-generator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-remap-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-block-scoped-functions@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-block-scoping@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-class-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-class-static-block@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-classes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-globals': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-computed-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/template': 7.29.7 - '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-destructuring@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-dotall-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-duplicate-keys@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-duplicate-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-dynamic-import@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-explicit-resource-management@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-exponentiation-operator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-export-namespace-from@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-for-of@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-function-name@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-json-strings@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-logical-assignment-operators@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-member-expression-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-amd@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-systemjs@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-systemjs@7.29.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-modules-umd@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-named-capturing-groups-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-new-target@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-nullish-coalescing-operator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-numeric-separator@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-object-rest-spread@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/traverse': 7.29.8(supports-color@7.2.0) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/traverse': 7.29.8(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-object-super@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-optional-catch-binding@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-optional-chaining@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-parameters@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-private-methods@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-private-property-in-object@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 transitivePeerDependencies: - supports-color - '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-property-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-react-jsx-self@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-react-jsx-source@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-regenerator@7.29.8(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-regexp-modifiers@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-reserved-words@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-shorthand-properties@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-spread@7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/plugin-transform-spread@7.29.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@7.2.0) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7(supports-color@10.2.2) transitivePeerDependencies: - supports-color - '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-sticky-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-template-literals@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-typeof-symbol@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-escapes@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-property-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/plugin-transform-unicode-sets-regex@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-create-regexp-features-plugin': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@babel/helper-plugin-utils': 7.29.7 - '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0)': + '@babel/preset-env@7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2)': dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/helper-validator-option': 7.29.7 - '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-systemjs': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-spread': 7.29.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0)) - babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) - babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/plugin-bugfix-firefox-class-in-computed-class-key': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-safari-class-field-initializer-scope': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-bugfix-safari-rest-destructuring-rhs-array': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-proposal-private-property-in-object': 7.21.0-placeholder-for-preset-env.2(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-assertions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-import-attributes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-unicode-sets-regex': 7.18.6(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-arrow-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-async-generator-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-async-to-generator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-block-scoped-functions': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-block-scoping': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-class-static-block': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-classes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-computed-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-destructuring': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-dotall-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-duplicate-keys': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-dynamic-import': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-explicit-resource-management': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-exponentiation-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-export-namespace-from': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-for-of': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-function-name': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-json-strings': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-logical-assignment-operators': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-member-expression-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-amd': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-systemjs': 7.29.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-modules-umd': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-named-capturing-groups-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-new-target': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-nullish-coalescing-operator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-numeric-separator': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-object-rest-spread': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-object-super': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-optional-catch-binding': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-optional-chaining': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-property-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regenerator': 7.29.8(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-regexp-modifiers': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-reserved-words': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-shorthand-properties': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-spread': 7.29.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-sticky-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-template-literals': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-typeof-symbol': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-escapes': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-property-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-unicode-sets-regex': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2)) + babel-plugin-polyfill-corejs2: 0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-corejs3: 0.14.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-polyfill-regenerator: 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.50.0 semver: 6.3.1 transitivePeerDependencies: - supports-color - '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@7.2.0))': + '@babel/preset-modules@0.1.6-no-external-plugins(@babel/core@7.29.7(supports-color@10.2.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/helper-plugin-utils': 7.29.7 '@babel/types': 7.29.8 esutils: 2.0.3 @@ -4040,7 +4141,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/types': 7.29.8 - '@babel/traverse@7.29.8(supports-color@7.2.0)': + '@babel/traverse@7.29.8(supports-color@10.2.2)': dependencies: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.8 @@ -4048,7 +4149,7 @@ snapshots: '@babel/parser': 7.29.9 '@babel/template': 7.29.7 '@babel/types': 7.29.8 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4188,17 +4289,17 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@7.2.0))': + '@eslint-community/eslint-utils@4.10.1(eslint@9.39.5(supports-color@10.2.2))': dependencies: - eslint: 9.39.5(supports-color@7.2.0) + eslint: 9.39.5(supports-color@10.2.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.2': {} - '@eslint/config-array@0.21.2(supports-color@7.2.0)': + '@eslint/config-array@0.21.2(supports-color@10.2.2)': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -4211,10 +4312,10 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.7(supports-color@7.2.0)': + '@eslint/eslintrc@3.3.7(supports-color@10.2.2)': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -4301,12 +4402,35 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.20.3 + '@redocly/ajv@8.11.2': + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + + '@redocly/config@0.22.0': {} + + '@redocly/openapi-core@1.34.20(supports-color@10.2.2)': + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.22.0 + colorette: 1.4.0 + https-proxy-agent: 7.0.6(supports-color@10.2.2) + js-levenshtein: 1.1.6 + js-yaml: 4.3.2 + minimatch: 5.1.9 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - supports-color + '@rolldown/pluginutils@1.0.0-rc.3': {} - '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/babel__core@7.20.5)(rollup@4.63.4)(supports-color@7.2.0)': + '@rollup/plugin-babel@6.1.0(@babel/core@7.29.7(supports-color@10.2.2))(@types/babel__core@7.20.5)(rollup@4.63.4)(supports-color@10.2.2)': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-module-imports': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) '@rollup/pluginutils': 5.4.0(rollup@4.63.4) optionalDependencies: '@types/babel__core': 7.20.5 @@ -4460,6 +4584,10 @@ snapshots: '@types/react': 19.3.0 '@types/react-dom': 19.3.0(@types/react@19.3.0) + '@testing-library/user-event@14.6.7(@testing-library/dom@10.4.2)': + dependencies: + '@testing-library/dom': 10.4.2 + '@trickfilm400/rollup-plugin-off-main-thread@3.0.0-pre1': dependencies: ejs: 3.1.10 @@ -4517,15 +4645,15 @@ snapshots: '@types/trusted-types@2.0.7': {} - '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/scope-manager': 8.70.1 - '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/type-utils': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.1 - eslint: 9.39.5(supports-color@7.2.0) + eslint: 9.39.5(supports-color@10.2.2) ignore: 7.0.9 natural-compare: 1.4.0 ts-api-utils: 2.5.0(typescript@5.9.3) @@ -4533,23 +4661,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/scope-manager': 8.70.1 '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.5(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/project-service@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/project-service@8.70.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) '@typescript-eslint/types': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4563,13 +4691,13 @@ snapshots: dependencies: typescript: 5.9.3 - '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - debug: 4.4.3(supports-color@7.2.0) - eslint: 9.39.5(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + debug: 4.4.3(supports-color@10.2.2) + eslint: 9.39.5(supports-color@10.2.2) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: @@ -4577,13 +4705,13 @@ snapshots: '@typescript-eslint/types@8.70.1': {} - '@typescript-eslint/typescript-estree@8.70.1(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/typescript-estree@8.70.1(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@typescript-eslint/project-service': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) + '@typescript-eslint/project-service': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) '@typescript-eslint/tsconfig-utils': 8.70.1(typescript@5.9.3) '@typescript-eslint/types': 8.70.1 '@typescript-eslint/visitor-keys': 8.70.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) minimatch: 10.2.6 semver: 7.8.5 tinyglobby: 0.2.17 @@ -4592,13 +4720,13 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)': + '@typescript-eslint/utils@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@10.2.2)) '@typescript-eslint/scope-manager': 8.70.1 '@typescript-eslint/types': 8.70.1 - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(supports-color@7.2.0) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -4608,11 +4736,11 @@ snapshots: '@typescript-eslint/types': 8.70.1 eslint-visitor-keys: 5.0.1 - '@vitejs/plugin-react@5.2.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))': + '@vitejs/plugin-react@5.2.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))': dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) - '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0)) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2)) '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 @@ -4684,6 +4812,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-colors@4.1.3: {} + ansi-regex@5.0.1: {} ansi-styles@4.3.0: @@ -4762,27 +4892,27 @@ snapshots: axobject-query@4.1.0: {} - babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-corejs2@0.4.17(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: '@babel/compat-data': 7.29.7 - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) semver: 6.3.1 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-corejs3@0.14.2(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) core-js-compat: 3.50.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0): + babel-plugin-polyfill-regenerator@0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/helper-define-polyfill-provider': 0.6.8(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -4869,6 +4999,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + change-case@5.4.4: {} + check-error@2.1.3: {} color-convert@2.0.1: @@ -4879,6 +5011,8 @@ snapshots: colord@2.10.0: {} + colorette@1.4.0: {} + commander@2.20.3: {} common-tags@1.8.2: {} @@ -4887,6 +5021,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie@1.1.1: {} + core-js-compat@3.50.0: dependencies: browserslist: 4.29.0 @@ -4953,11 +5089,11 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 - debug@4.4.3(supports-color@7.2.0): + debug@4.4.3(supports-color@10.2.2): dependencies: ms: 2.1.3 optionalDependencies: - supports-color: 7.2.0 + supports-color: 10.2.2 decimal.js@10.6.0: {} @@ -5140,7 +5276,7 @@ snapshots: escape-string-regexp@4.0.0: {} - eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(supports-color@7.2.0)): + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.5(supports-color@10.2.2)): dependencies: aria-query: 5.3.2 array-includes: 3.2.0 @@ -5150,7 +5286,7 @@ snapshots: axobject-query: 4.1.0 damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 - eslint: 9.39.5(supports-color@7.2.0) + eslint: 9.39.5(supports-color@10.2.2) hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 @@ -5159,11 +5295,11 @@ snapshots: safe-regex-test: 1.1.0 string.prototype.includes: 2.0.1 - eslint-plugin-react-hooks@6.1.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0): + eslint-plugin-react-hooks@6.1.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2): dependencies: - '@babel/core': 7.29.7(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) '@babel/parser': 7.29.9 - eslint: 9.39.5(supports-color@7.2.0) + eslint: 9.39.5(supports-color@10.2.2) zod: 4.6.5 zod-validation-error: 4.0.2(zod@4.6.5) transitivePeerDependencies: @@ -5180,14 +5316,14 @@ snapshots: eslint-visitor-keys@5.0.1: {} - eslint@9.39.5(supports-color@7.2.0): + eslint@9.39.5(supports-color@10.2.2): dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@7.2.0)) + '@eslint-community/eslint-utils': 4.10.1(eslint@9.39.5(supports-color@10.2.2)) '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.21.2(supports-color@7.2.0) + '@eslint/config-array': 0.21.2(supports-color@10.2.2) '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.7(supports-color@7.2.0) + '@eslint/eslintrc': 3.3.7(supports-color@10.2.2) '@eslint/js': 9.39.5 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -5197,7 +5333,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -5465,17 +5601,17 @@ snapshots: html-tags@3.3.1: {} - http-proxy-agent@7.0.2(supports-color@7.2.0): + http-proxy-agent@7.0.2(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color - https-proxy-agent@7.0.6(supports-color@7.2.0): + https-proxy-agent@7.0.6(supports-color@10.2.2): dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -5504,6 +5640,8 @@ snapshots: indent-string@4.0.0: {} + index-to-position@1.2.0: {} + ini@1.3.8: {} internal-slot@1.1.0: @@ -5654,6 +5792,8 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 + js-levenshtein@1.1.6: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -5662,7 +5802,7 @@ snapshots: dependencies: argparse: 2.0.1 - jsdom@27.4.0(supports-color@7.2.0): + jsdom@27.4.0(supports-color@10.2.2): dependencies: '@acemir/cssom': 0.9.31 '@asamuzakjp/dom-selector': 6.8.1 @@ -5671,8 +5811,8 @@ snapshots: data-urls: 6.0.1 decimal.js: 10.6.0 html-encoding-sniffer: 6.0.0 - http-proxy-agent: 7.0.2(supports-color@7.2.0) - https-proxy-agent: 7.0.6(supports-color@7.2.0) + http-proxy-agent: 7.0.2(supports-color@10.2.2) + https-proxy-agent: 7.0.6(supports-color@10.2.2) is-potential-custom-element-name: 1.0.1 parse5: 8.0.1 saxes: 6.0.0 @@ -5838,6 +5978,22 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + + openapi-typescript-helpers@0.0.15: {} + + openapi-typescript@7.13.0(typescript@5.9.3): + dependencies: + '@redocly/openapi-core': 1.34.20(supports-color@10.2.2) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.3.0 + supports-color: 10.2.2 + typescript: 5.9.3 + yargs-parser: 21.1.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5875,6 +6031,12 @@ snapshots: json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-json@8.3.0: + dependencies: + '@babel/code-frame': 7.29.7 + index-to-position: 1.2.0 + type-fest: 4.41.0 + parse5@8.0.1: dependencies: entities: 8.1.0 @@ -5902,6 +6064,8 @@ snapshots: picomatch@4.0.7: {} + pluralize@8.0.0: {} + possible-typed-array-names@1.1.0: {} postcss-resolve-nested-selector@0.1.6: {} @@ -5965,6 +6129,14 @@ snapshots: react-refresh@0.18.0: {} + react-router@7.18.4(react-dom@19.3.0(react@19.3.0))(react@19.3.0): + dependencies: + cookie: 1.1.1 + react: 19.3.0 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.3.0(react@19.3.0) + react@19.3.0: {} redent@3.0.0: @@ -6095,6 +6267,8 @@ snapshots: serialize-javascript@7.1.1: {} + set-cookie-parser@2.7.2: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 @@ -6259,16 +6433,16 @@ snapshots: dependencies: js-tokens: 9.0.1 - stylelint-config-recommended@17.0.0(stylelint@16.26.1(supports-color@7.2.0)(typescript@5.9.3)): + stylelint-config-recommended@17.0.0(stylelint@16.26.1(supports-color@10.2.2)(typescript@5.9.3)): dependencies: - stylelint: 16.26.1(supports-color@7.2.0)(typescript@5.9.3) + stylelint: 16.26.1(supports-color@10.2.2)(typescript@5.9.3) - stylelint-config-standard@39.0.1(stylelint@16.26.1(supports-color@7.2.0)(typescript@5.9.3)): + stylelint-config-standard@39.0.1(stylelint@16.26.1(supports-color@10.2.2)(typescript@5.9.3)): dependencies: - stylelint: 16.26.1(supports-color@7.2.0)(typescript@5.9.3) - stylelint-config-recommended: 17.0.0(stylelint@16.26.1(supports-color@7.2.0)(typescript@5.9.3)) + stylelint: 16.26.1(supports-color@10.2.2)(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(supports-color@10.2.2)(typescript@5.9.3)) - stylelint@16.26.1(supports-color@7.2.0)(typescript@5.9.3): + stylelint@16.26.1(supports-color@10.2.2)(typescript@5.9.3): dependencies: '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) '@csstools/css-syntax-patches-for-csstree': 1.1.14(css-tree@3.2.1) @@ -6281,7 +6455,7 @@ snapshots: cosmiconfig: 9.0.2(typescript@5.9.3) css-functions-list: 3.3.3 css-tree: 3.2.1 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 file-entry-cache: 11.1.5 @@ -6313,6 +6487,8 @@ snapshots: - supports-color - typescript + supports-color@10.2.2: {} + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6395,6 +6571,8 @@ snapshots: type-fest@0.16.0: {} + type-fest@4.41.0: {} + typed-array-buffer@1.0.3: dependencies: call-bound: 1.0.4 @@ -6427,13 +6605,13 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3): + typescript-eslint@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/typescript-estree': 8.70.1(supports-color@7.2.0)(typescript@5.9.3) - '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3) - eslint: 9.39.5(supports-color@7.2.0) + '@typescript-eslint/eslint-plugin': 8.70.1(@typescript-eslint/parser@8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3))(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/parser': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.70.1(supports-color@10.2.2)(typescript@5.9.3) + '@typescript-eslint/utils': 8.70.1(eslint@9.39.5(supports-color@10.2.2))(supports-color@10.2.2)(typescript@5.9.3) + eslint: 9.39.5(supports-color@10.2.2) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6474,6 +6652,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + uri-js-replace@1.0.1: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -6484,10 +6664,10 @@ snapshots: util-deprecate@1.0.2: {} - vite-node@3.2.4(@types/node@24.13.6)(supports-color@7.2.0)(terser@5.51.2): + vite-node@3.2.4(@types/node@24.13.6)(supports-color@10.2.2)(terser@5.51.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 7.3.6(@types/node@24.13.6)(terser@5.51.2) @@ -6505,13 +6685,13 @@ snapshots: - tsx - yaml - vite-plugin-pwa@1.3.0(supports-color@7.2.0)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))(workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@7.2.0))(workbox-window@7.4.1): + vite-plugin-pwa@1.3.0(supports-color@10.2.2)(vite@7.3.6(@types/node@24.13.6)(terser@5.51.2))(workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@10.2.2))(workbox-window@7.4.1): dependencies: - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) pretty-bytes: 6.1.1 tinyglobby: 0.2.17 vite: 7.3.6(@types/node@24.13.6)(terser@5.51.2) - workbox-build: 7.4.1(@types/babel__core@7.20.5)(supports-color@7.2.0) + workbox-build: 7.4.1(@types/babel__core@7.20.5)(supports-color@10.2.2) workbox-window: 7.4.1 transitivePeerDependencies: - supports-color @@ -6529,7 +6709,7 @@ snapshots: fsevents: 2.3.3 terser: 5.51.2 - vitest@3.2.7(@types/node@24.13.6)(jsdom@27.4.0(supports-color@7.2.0))(supports-color@7.2.0)(terser@5.51.2): + vitest@3.2.7(@types/node@24.13.6)(jsdom@27.4.0(supports-color@10.2.2))(supports-color@10.2.2)(terser@5.51.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -6540,7 +6720,7 @@ snapshots: '@vitest/spy': 3.2.7 '@vitest/utils': 3.2.7 chai: 5.3.3 - debug: 4.4.3(supports-color@7.2.0) + debug: 4.4.3(supports-color@10.2.2) expect-type: 1.4.0 magic-string: 0.30.21 pathe: 2.0.3 @@ -6552,11 +6732,11 @@ snapshots: tinypool: 1.1.1 tinyrainbow: 2.0.0 vite: 7.3.6(@types/node@24.13.6)(terser@5.51.2) - vite-node: 3.2.4(@types/node@24.13.6)(supports-color@7.2.0)(terser@5.51.2) + vite-node: 3.2.4(@types/node@24.13.6)(supports-color@10.2.2)(terser@5.51.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 24.13.6 - jsdom: 27.4.0(supports-color@7.2.0) + jsdom: 27.4.0(supports-color@10.2.2) transitivePeerDependencies: - jiti - less @@ -6653,13 +6833,13 @@ snapshots: dependencies: workbox-core: 7.4.1 - workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@7.2.0): + workbox-build@7.4.1(@types/babel__core@7.20.5)(supports-color@10.2.2): dependencies: '@apideck/better-ajv-errors': 0.3.7(ajv@8.20.0) - '@babel/core': 7.29.7(supports-color@7.2.0) - '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@7.2.0))(supports-color@7.2.0) + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/preset-env': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) '@babel/runtime': 7.29.7 - '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7(supports-color@7.2.0))(@types/babel__core@7.20.5)(rollup@4.63.4)(supports-color@7.2.0) + '@rollup/plugin-babel': 6.1.0(@babel/core@7.29.7(supports-color@10.2.2))(@types/babel__core@7.20.5)(rollup@4.63.4)(supports-color@10.2.2) '@rollup/plugin-node-resolve': 16.0.3(rollup@4.63.4) '@rollup/plugin-replace': 6.0.3(rollup@4.63.4) '@rollup/plugin-terser': 1.0.0(rollup@4.63.4) @@ -6770,6 +6950,10 @@ snapshots: yallist@3.1.1: {} + yaml-ast-parser@0.0.43: {} + + yargs-parser@21.1.1: {} + yocto-queue@0.1.0: {} zod-validation-error@4.0.2(zod@4.6.5): diff --git a/frontend/src/app/App.module.css b/frontend/src/app/App.module.css deleted file mode 100644 index f390a2f..0000000 --- a/frontend/src/app/App.module.css +++ /dev/null @@ -1,46 +0,0 @@ -.page { - min-height: 100vh; - display: grid; - grid-template-rows: auto 1fr; -} - -/* the brand block is always on the dark paper */ -.brand { - background: var(--color-brand-paper); - color: var(--color-brand-ink); - display: grid; - justify-items: center; - gap: var(--space-3); - padding: var(--space-7) var(--space-4) var(--space-6); -} - -.tagline { - color: var(--color-brand-ink); - opacity: 0.8; - text-align: center; -} - -.main { - display: grid; - gap: var(--space-4); - max-width: 40rem; - width: 100%; - margin: 0 auto; - padding: var(--space-6) var(--space-4); -} - -.language { - display: grid; - gap: var(--space-2); - justify-items: start; -} - -.language select { - min-height: var(--touch-target); - padding: 0 var(--space-3); - border: 1px solid var(--color-border); - border-radius: var(--radius-control); - background: var(--color-surface); - color: var(--color-ink); - font: inherit; -} diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index f3a94d1..73f232d 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -1,32 +1,36 @@ -import { useTranslation } from "react-i18next"; -import { supportedLanguages, type Language } from "@/lib/i18n"; -import styles from "./App.module.css"; +import { BrowserRouter, Route, Routes } from "react-router"; +import { ClaimPage } from "@/features/auth/ClaimPage"; +import { LoginPage } from "@/features/auth/LoginPage"; +import { RequireAuth } from "@/features/auth/RequireAuth"; +import { HomePage } from "@/features/persons/HomePage"; +import { PersonPage } from "@/features/persons/PersonPage"; +import { SettingsPage } from "@/features/settings/SettingsPage"; +import { Shell } from "./Shell"; + +export function AppRoutes() { + return ( + + } /> + } /> + + + + } + > + } /> + } /> + } /> + + + ); +} export function App() { - const { t, i18n } = useTranslation(); return ( -
-
- {t("app.name")} -

{t("app.tagline")}

-
-
-

{t("shell.comingSoon")}

-

{t("app.intendedUse")}

- -
-
+ + + ); } diff --git a/frontend/src/app/Shell.module.css b/frontend/src/app/Shell.module.css new file mode 100644 index 0000000..6688357 --- /dev/null +++ b/frontend/src/app/Shell.module.css @@ -0,0 +1,45 @@ +.page { + min-height: 100vh; + display: grid; + grid-template-rows: auto 1fr; +} + +.brand { + background: var(--color-brand-paper); + color: var(--color-brand-ink); + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-4); + padding: var(--space-3) var(--space-4); +} + +.brand img { + display: block; +} + +.nav { + display: flex; + gap: var(--space-4); +} + +.nav a { + color: var(--color-brand-ink); + text-decoration: none; + font-weight: var(--font-weight-emphasis); + min-height: var(--touch-target); + display: inline-flex; + align-items: center; + border-bottom: 2px solid transparent; +} + +.nav a.active { + border-bottom-color: var(--color-brand-accent); +} + +.main { + width: 100%; + max-width: 48rem; + margin: 0 auto; + padding: var(--space-5) var(--space-4) var(--space-8); +} diff --git a/frontend/src/app/Shell.tsx b/frontend/src/app/Shell.tsx new file mode 100644 index 0000000..a24f669 --- /dev/null +++ b/frontend/src/app/Shell.tsx @@ -0,0 +1,28 @@ +import { NavLink, Outlet } from "react-router"; +import { useTranslation } from "react-i18next"; +import styles from "./Shell.module.css"; + +/** Brand block on the dark paper, content below, a small navigation bar. */ +export function Shell() { + const { t } = useTranslation(); + return ( +
+
+ + + + +
+
+ +
+
+ ); +} diff --git a/frontend/src/design-system/components/Button.module.css b/frontend/src/design-system/components/Button.module.css new file mode 100644 index 0000000..d121142 --- /dev/null +++ b/frontend/src/design-system/components/Button.module.css @@ -0,0 +1,37 @@ +.button { + min-height: var(--touch-target); + padding: 0 var(--space-4); + border: 1px solid transparent; + border-radius: var(--radius-control); + font: inherit; + font-weight: var(--font-weight-emphasis); + cursor: pointer; + transition: background var(--motion-fast) var(--ease); +} + +.button:disabled { + opacity: 0.55; + cursor: default; +} + +.primary { + background: var(--color-accent); + color: var(--color-paper); +} + +.secondary { + background: var(--color-surface); + border-color: var(--color-border); + color: var(--color-ink); +} + +.quiet { + background: transparent; + color: var(--color-accent); +} + +.danger { + background: transparent; + border-color: var(--color-danger); + color: var(--color-danger); +} diff --git a/frontend/src/design-system/components/Button.tsx b/frontend/src/design-system/components/Button.tsx new file mode 100644 index 0000000..1f52624 --- /dev/null +++ b/frontend/src/design-system/components/Button.tsx @@ -0,0 +1,14 @@ +import type { ButtonHTMLAttributes } from "react"; +import styles from "./Button.module.css"; + +type Variant = "primary" | "secondary" | "quiet" | "danger"; + +export function Button({ + variant = "primary", + className, + ...props +}: ButtonHTMLAttributes & { variant?: Variant }) { + return ( + + + ); +} diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx new file mode 100644 index 0000000..f0cdd41 --- /dev/null +++ b/frontend/src/features/auth/LoginPage.tsx @@ -0,0 +1,22 @@ +import { useTranslation } from "react-i18next"; +import { Navigate } from "react-router"; +import { useInstance, useLogin, useSession } from "@/lib/auth/session"; +import { CredentialsForm } from "./CredentialsForm"; + +export function LoginPage() { + const { t } = useTranslation(); + const session = useSession(); + const instance = useInstance(); + const login = useLogin(); + if (session.data) return ; + if (instance.data && !instance.data.claimed) return ; + return ( + login.mutate(credentials)} + /> + ); +} diff --git a/frontend/src/features/auth/RequireAuth.tsx b/frontend/src/features/auth/RequireAuth.tsx new file mode 100644 index 0000000..e2ccc0a --- /dev/null +++ b/frontend/src/features/auth/RequireAuth.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; +import { Navigate } from "react-router"; +import { useInstance, useSession } from "@/lib/auth/session"; + +/** Sends visitors to the claim page on a fresh instance and to the login page otherwise. */ +export function RequireAuth({ children }: { children: ReactNode }) { + const session = useSession(); + const instance = useInstance(); + if (session.isPending || instance.isPending) return

…

; + if (!session.data) + return ; + return <>{children}; +} diff --git a/frontend/src/features/auth/auth.module.css b/frontend/src/features/auth/auth.module.css new file mode 100644 index 0000000..b8aa187 --- /dev/null +++ b/frontend/src/features/auth/auth.module.css @@ -0,0 +1,12 @@ +.form { + display: grid; + gap: var(--space-4); + max-width: 26rem; + width: 100%; + margin: 0 auto; + padding: var(--space-6) var(--space-4); +} + +.form h1 { + font-size: var(--text-2xl); +} diff --git a/frontend/src/features/persons/HomePage.tsx b/frontend/src/features/persons/HomePage.tsx new file mode 100644 index 0000000..05866f2 --- /dev/null +++ b/frontend/src/features/persons/HomePage.tsx @@ -0,0 +1,72 @@ +import { useState, type FormEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router"; +import { Button } from "@/design-system/components/Button"; +import { Card } from "@/design-system/components/Card"; +import { EmptyState } from "@/design-system/components/EmptyState"; +import { Notice } from "@/design-system/components/Notice"; +import { TextField } from "@/design-system/components/TextField"; +import { useCreatePerson, usePersons } from "@/lib/persons"; +import styles from "./persons.module.css"; + +export function HomePage() { + const { t } = useTranslation(); + const persons = usePersons(); + const create = useCreatePerson(); + const [name, setName] = useState(""); + const [adding, setAdding] = useState(false); + + function submit(event: FormEvent) { + event.preventDefault(); + create.mutate({ display_name: name }, { onSuccess: () => (setName(""), setAdding(false)) }); + } + + const form = ( +
+ setName(e.target.value)} required /> +
+ + +
+ {create.error && {create.error.message}} + + ); + + return ( +
+
+

{t("persons.title")}

+ {!adding && persons.data && persons.data.length > 0 && ( + + )} +
+ {persons.error && {persons.error.message}} + {persons.data && persons.data.length === 0 && !adding && ( + setAdding(true)}>{t("persons.add")}} + /> + )} + {adding && form} +
    + {persons.data?.map((person) => ( +
  • + + + {person.display_name} + {t(`persons.role.${person.my_role}`)} + + +
  • + ))} +
+
+ ); +} diff --git a/frontend/src/features/persons/PersonPage.tsx b/frontend/src/features/persons/PersonPage.tsx new file mode 100644 index 0000000..ca5d40e --- /dev/null +++ b/frontend/src/features/persons/PersonPage.tsx @@ -0,0 +1,154 @@ +import { useRef, useState, type ChangeEvent } from "react"; +import { useTranslation } from "react-i18next"; +import { Link, useParams } from "react-router"; +import { Button } from "@/design-system/components/Button"; +import { EmptyState } from "@/design-system/components/EmptyState"; +import { Notice } from "@/design-system/components/Notice"; +import { + imageUrl, + useDeleteImage, + useImages, + useUploadImage, + type ImageOut, + type ImageRole, +} from "@/lib/images"; +import { usePerson } from "@/lib/persons"; +import styles from "./persons.module.css"; + +const ROLES: ImageRole[] = ["close_up", "with_reference", "overview", "other"]; + +export function PersonPage() { + const { personId = "" } = useParams(); + const { t, i18n } = useTranslation(); + const person = usePerson(personId); + const images = useImages(personId); + const upload = useUploadImage(personId); + const remove = useDeleteImage(personId); + const [role, setRole] = useState("close_up"); + const [open, setOpen] = useState(null); + const dialog = useRef(null); + const canEdit = person.data?.my_role === "owner" || person.data?.my_role === "manager"; + + function onFile(event: ChangeEvent) { + const file = event.target.files?.[0]; + event.target.value = ""; + if (file) upload.mutate({ file, role }); + } + + function show(image: ImageOut) { + setOpen(image); + dialog.current?.showModal(); + } + + const dateFormat = new Intl.DateTimeFormat(i18n.resolvedLanguage, { + dateStyle: "medium", + timeStyle: "short", + }); + + return ( +
+

+ {t("common.back")} +

+
+

{person.data?.display_name ?? "…"}

+ {person.data && {t(`persons.role.${person.data.my_role}`)}} +
+ {person.error && {person.error.message}} + + {canEdit && ( +
+

{t("images.addPhoto")}

+
+ + + +
+

{t("images.privacyNote")}

+ {upload.error && {upload.error.message}} + {upload.isSuccess && {t("images.uploaded")}} +
+ )} + + {images.data && images.data.length === 0 && ( + + )} +
    + {images.data?.map((image) => ( +
  • + +
  • + ))} +
+ + setOpen(null)}> + {open && ( + <> + {t(`images.roles.${open.role}`)} +
+ + {open.captured_at ? dateFormat.format(new Date(open.captured_at)) : t("images.noDate")} ·{" "} + {open.width}×{open.height} + + + {canEdit && ( + + )}{" "} + + +
+ + )} +
+
+ ); +} diff --git a/frontend/src/features/persons/persons.module.css b/frontend/src/features/persons/persons.module.css new file mode 100644 index 0000000..b433b2e --- /dev/null +++ b/frontend/src/features/persons/persons.module.css @@ -0,0 +1,129 @@ +.page { + display: grid; + gap: var(--space-4); +} + +.header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); +} + +.header h1 { + font-size: var(--text-2xl); +} + +.addForm { + display: grid; + gap: var(--space-3); +} + +.actions { + display: flex; + gap: var(--space-2); +} + +.list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: var(--space-3); +} + +.personLink { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--space-3); + color: inherit; + text-decoration: none; +} + +.personName { + font-weight: var(--font-weight-emphasis); + font-size: var(--text-lg); +} + +.gallery { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: var(--space-2); +} + +.thumb { + display: block; + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: var(--radius-control); + background: var(--color-photo-frame); + border: 0; + padding: 0; + cursor: pointer; +} + +.upload { + display: grid; + gap: var(--space-3); +} + +.roleRow { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + align-items: center; +} + +.roleRow select { + min-height: var(--touch-target); + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-ink); + font: inherit; +} + +.fileButton { + position: relative; + overflow: hidden; +} + +.fileButton input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.preview { + border: 0; + padding: 0; + background: var(--color-brand-paper); + border-radius: var(--radius-card); + max-width: min(96vw, 1024px); +} + +.preview::backdrop { + background: rgb(0 0 0 / 70%); +} + +.preview img { + display: block; + max-width: 100%; + max-height: 80vh; +} + +.previewBar { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + color: var(--color-brand-ink); +} diff --git a/frontend/src/features/settings/SettingsPage.tsx b/frontend/src/features/settings/SettingsPage.tsx new file mode 100644 index 0000000..12af3b4 --- /dev/null +++ b/frontend/src/features/settings/SettingsPage.tsx @@ -0,0 +1,73 @@ +import { useTranslation } from "react-i18next"; +import { Button } from "@/design-system/components/Button"; +import { Card } from "@/design-system/components/Card"; +import { Notice } from "@/design-system/components/Notice"; +import { useInstance, useLogout, useSession, useUpdateMe } from "@/lib/auth/session"; +import { supportedLanguages } from "@/lib/i18n"; +import styles from "./settings.module.css"; + +const THEMES = ["system", "light", "dark"] as const; + +export function SettingsPage() { + const { t } = useTranslation(); + const session = useSession(); + const instance = useInstance(); + const update = useUpdateMe(); + const logout = useLogout(); + const user = session.data?.user; + if (!user) return null; + return ( +
+

{t("settings.title")}

+ + + + + {update.error && {update.error.message}} + + +

+ {user.username} · {t(`settings.roles.${user.role}`)} +

+ +
+ +

{t("app.intendedUse")}

+

neVus {instance.data?.version}

+
+
+ ); +} diff --git a/frontend/src/features/settings/settings.module.css b/frontend/src/features/settings/settings.module.css new file mode 100644 index 0000000..c5151c0 --- /dev/null +++ b/frontend/src/features/settings/settings.module.css @@ -0,0 +1,37 @@ +.page { + display: grid; + gap: var(--space-4); +} + +.page h1 { + font-size: var(--text-2xl); +} + +.group { + display: grid; + gap: var(--space-3); +} + +.row { + display: flex; + justify-content: space-between; + align-items: center; + gap: var(--space-3); + min-height: var(--touch-target); +} + +.row select { + min-height: var(--touch-target); + padding: 0 var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-control); + background: var(--color-surface); + color: var(--color-ink); + font: inherit; +} + +.row input[type="checkbox"] { + width: 24px; + height: 24px; + accent-color: var(--color-accent); +} diff --git a/frontend/src/lib/api/client.ts b/frontend/src/lib/api/client.ts new file mode 100644 index 0000000..f9169a7 --- /dev/null +++ b/frontend/src/lib/api/client.ts @@ -0,0 +1,31 @@ +import createClient from "openapi-fetch"; +import type { paths } from "./schema"; + +/** Typed client for the backend API. Cookies carry the session; the server checks fetch metadata for CSRF. */ +// The page's own origin: the API lives next to the app, and absolute URLs also work where Request needs them. +const origin = typeof window === "undefined" ? "http://localhost" : window.location.origin; + +export const api = createClient({ + baseUrl: origin, + credentials: "same-origin", + // Resolve fetch at call time so a test can replace it after this module loaded. + fetch: (request) => globalThis.fetch(request), +}); + +interface FastApiError { + detail?: string | { msg: string; loc?: (string | number)[] }[]; +} + +/** Turn a FastAPI error body into one sentence for people. */ +export function errorMessage(error: unknown, fallback: string): string { + if (error && typeof error === "object" && "detail" in error) { + const detail = (error as FastApiError).detail; + if (typeof detail === "string") return detail; + if (Array.isArray(detail) && detail.length > 0 && detail[0]) { + const first = detail[0]; + const field = first.loc?.filter((part) => typeof part === "string" && part !== "body").join("."); + return field ? `${field}: ${first.msg}` : first.msg; + } + } + return fallback; +} diff --git a/frontend/src/lib/api/schema.d.ts b/frontend/src/lib/api/schema.d.ts new file mode 100644 index 0000000..099805c --- /dev/null +++ b/frontend/src/lib/api/schema.d.ts @@ -0,0 +1,1464 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/api/auth/claim": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Claim + * @description The first person in claims the instance and becomes its administrator. Refused once anybody exists. + */ + post: operations["claim_api_auth_claim_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/instance": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Instance Status */ + get: operations["instance_status_api_auth_instance_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/login": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Login */ + post: operations["login_api_auth_login_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/logout": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Logout */ + post: operations["logout_api_auth_logout_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** Update Me */ + patch: operations["update_me_api_auth_me_patch"]; + trace?: never; + }; + "/api/auth/me/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Change Password */ + post: operations["change_password_api_auth_me_password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/session": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Session Info */ + get: operations["session_info_api_auth_session_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/auth/sudo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Sudo + * @description Re-authenticate for a few minutes before destructive actions. + */ + post: operations["sudo_api_auth_sudo_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/images/{image_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Image */ + get: operations["get_image_api_images__image_id__get"]; + put?: never; + post?: never; + /** + * Delete Image + * @description Moves the image to the trash; the purge and the garbage collection of blobs arrive with data management. + */ + delete: operations["delete_image_api_images__image_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/images/{image_id}/{kind}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Image File + * @description The bytes, only for people who may see the person; cacheable forever because the content is addressed by hash. + */ + get: operations["image_file_api_images__image_id___kind__get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/persons": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Persons */ + get: operations["list_persons_api_persons_get"]; + put?: never; + /** Create Person */ + post: operations["create_person_api_persons_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/persons/{person_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Person */ + get: operations["get_person_api_persons__person_id__get"]; + put?: never; + post?: never; + /** + * Delete Person + * @description Soft delete (30-day trash); the purge arrives with the data-management features. Needs sudo mode. + */ + delete: operations["delete_person_api_persons__person_id__delete"]; + options?: never; + head?: never; + /** Update Person */ + patch: operations["update_person_api_persons__person_id__patch"]; + trace?: never; + }; + "/api/persons/{person_id}/access": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Access */ + get: operations["list_access_api_persons__person_id__access_get"]; + /** Grant Access */ + put: operations["grant_access_api_persons__person_id__access_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/persons/{person_id}/access/{user_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Revoke Access */ + delete: operations["revoke_access_api_persons__person_id__access__user_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/persons/{person_id}/images": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Images */ + get: operations["list_images_api_persons__person_id__images_get"]; + put?: never; + /** Upload Image */ + post: operations["upload_image_api_persons__person_id__images_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Users */ + get: operations["list_users_api_users_get"]; + put?: never; + /** Create User */ + post: operations["create_user_api_users_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Disable User */ + post: operations["disable_user_api_users__user_id__disable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Enable User */ + post: operations["enable_user_api_users__user_id__enable_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/users/{user_id}/password": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Reset Password */ + post: operations["reset_password_api_users__user_id__password_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/healthz": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Healthz */ + get: operations["healthz_healthz_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + /** AccessIn */ + AccessIn: { + /** + * Role + * @enum {string} + */ + role: "manager" | "viewer"; + /** Username */ + username: string; + }; + /** AccessOut */ + AccessOut: { + /** + * Role + * @enum {string} + */ + role: "owner" | "manager" | "viewer"; + /** + * User Id + * Format: uuid + */ + user_id: string; + /** Username */ + username: string; + }; + /** Body_upload_image_api_persons__person_id__images_post */ + Body_upload_image_api_persons__person_id__images_post: { + /** Captured At */ + captured_at?: string | null; + /** Captured Tz */ + captured_tz?: string | null; + /** File */ + file: string; + /** + * Modality + * @default camera + * @enum {string} + */ + modality: "camera" | "dermatoscope"; + /** + * Role + * @default close_up + * @enum {string} + */ + role: "overview" | "close_up" | "with_reference" | "other"; + }; + /** Credentials */ + Credentials: { + /** Password */ + password: string; + /** Username */ + username: string; + }; + /** HTTPValidationError */ + HTTPValidationError: { + /** Detail */ + detail?: components["schemas"]["ValidationError"][]; + }; + /** Health */ + Health: { + /** + * Database + * @enum {string} + */ + database: "ok" | "unavailable"; + /** + * Status + * @enum {string} + */ + status: "ok" | "degraded"; + /** + * Storage + * @enum {string} + */ + storage: "ok" | "unavailable"; + /** Version */ + version: string; + }; + /** ImageOut */ + ImageOut: { + /** Bytes */ + bytes: number; + /** Captured At */ + captured_at: string | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Height */ + height: number; + /** + * Id + * Format: uuid + */ + id: string; + /** Mime */ + mime: string; + /** Modality */ + modality: string; + /** Orientation */ + orientation: number; + /** + * Person Id + * Format: uuid + */ + person_id: string; + /** Re Encoded */ + re_encoded: boolean; + /** Renditions */ + renditions: components["schemas"]["RenditionOut"][]; + /** Role */ + role: string; + /** Sha256 */ + sha256: string; + /** Source Format */ + source_format: string; + /** Width */ + width: number; + }; + /** InstanceStatus */ + InstanceStatus: { + /** Claimed */ + claimed: boolean; + /** Version */ + version: string; + }; + /** PasswordChange */ + PasswordChange: { + /** Current Password */ + current_password: string; + /** New Password */ + new_password: string; + }; + /** PasswordReset */ + PasswordReset: { + /** New Password */ + new_password: string; + }; + /** PersonIn */ + PersonIn: { + /** Birth Year */ + birth_year?: number | null; + /** Display Name */ + display_name: string; + /** Skin Tone */ + skin_tone?: string | null; + }; + /** PersonOut */ + PersonOut: { + /** Birth Year */ + birth_year: number | null; + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Display Name */ + display_name: string; + /** Experimental Analysis */ + experimental_analysis: boolean; + /** + * Id + * Format: uuid + */ + id: string; + /** + * My Role + * @enum {string} + */ + my_role: "owner" | "manager" | "viewer"; + /** + * Owner User Id + * Format: uuid + */ + owner_user_id: string; + /** Skin Tone */ + skin_tone: string | null; + /** + * Updated At + * Format: date-time + */ + updated_at: string; + }; + /** PersonUpdate */ + PersonUpdate: { + /** Birth Year */ + birth_year?: number | null; + /** Display Name */ + display_name?: string | null; + /** Experimental Analysis */ + experimental_analysis?: boolean | null; + /** Skin Tone */ + skin_tone?: string | null; + }; + /** RenditionOut */ + RenditionOut: { + /** Bytes */ + bytes: number; + /** Height */ + height: number; + /** Kind */ + kind: string; + /** Width */ + width: number; + }; + /** SessionOut */ + SessionOut: { + /** + * Instance Claimed + * @default true + */ + instance_claimed: boolean; + /** Sudo Until */ + sudo_until: string | null; + user: components["schemas"]["UserOut"]; + }; + /** SudoIn */ + SudoIn: { + /** Password */ + password: string; + }; + /** UserCreate */ + UserCreate: { + /** Email */ + email?: string | null; + /** Password */ + password: string; + /** + * Role + * @default member + * @enum {string} + */ + role: "admin" | "member"; + /** Username */ + username: string; + }; + /** UserOut */ + UserOut: { + /** + * Created At + * Format: date-time + */ + created_at: string; + /** Disabled At */ + disabled_at: string | null; + /** Email */ + email: string | null; + /** + * Id + * Format: uuid + */ + id: string; + /** Language */ + language: string; + /** Last Login At */ + last_login_at: string | null; + /** + * Role + * @enum {string} + */ + role: "admin" | "member"; + /** Show Uncertainty */ + show_uncertainty: boolean; + /** Theme */ + theme: string; + /** Username */ + username: string; + }; + /** UserUpdateMe */ + UserUpdateMe: { + /** Email */ + email?: string | null; + /** Language */ + language?: ("en" | "es") | null; + /** Show Uncertainty */ + show_uncertainty?: boolean | null; + /** Theme */ + theme?: ("system" | "light" | "dark") | null; + }; + /** ValidationError */ + ValidationError: { + /** Context */ + ctx?: Record; + /** Input */ + input?: unknown; + /** Location */ + loc: (string | number)[]; + /** Message */ + msg: string; + /** Error Type */ + type: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + claim_api_auth_claim_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Credentials"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + instance_status_api_auth_instance_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["InstanceStatus"]; + }; + }; + }; + }; + login_api_auth_login_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Credentials"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + logout_api_auth_logout_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + update_me_api_auth_me_patch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserUpdateMe"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + change_password_api_auth_me_password_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordChange"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + session_info_api_auth_session_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionOut"]; + }; + }; + }; + }; + sudo_api_auth_sudo_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SudoIn"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SessionOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_image_api_images__image_id__get: { + parameters: { + query?: never; + header?: never; + path: { + image_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_image_api_images__image_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + image_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + image_file_api_images__image_id___kind__get: { + parameters: { + query?: never; + header?: never; + path: { + image_id: string; + kind: "original" | "full" | "preview" | "thumb"; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_persons_api_persons_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"][]; + }; + }; + }; + }; + create_person_api_persons_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonIn"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_person_api_persons__person_id__get: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_person_api_persons__person_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_person_api_persons__person_id__patch: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_access_api_persons__person_id__access_get: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + grant_access_api_persons__person_id__access_put: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AccessIn"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AccessOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + revoke_access_api_persons__person_id__access__user_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_images_api_persons__person_id__images_get: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageOut"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + upload_image_api_persons__person_id__images_post: { + parameters: { + query?: never; + header?: never; + path: { + person_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_image_api_persons__person_id__images_post"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + list_users_api_users_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"][]; + }; + }; + }; + }; + create_user_api_users_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UserCreate"]; + }; + }; + responses: { + /** @description Successful Response */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + disable_user_api_users__user_id__disable_post: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + enable_user_api_users__user_id__enable_post: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserOut"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + reset_password_api_users__user_id__password_post: { + parameters: { + query?: never; + header?: never; + path: { + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PasswordReset"]; + }; + }; + responses: { + /** @description Successful Response */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + healthz_healthz_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Health"]; + }; + }; + }; + }; +} diff --git a/frontend/src/lib/auth/session.ts b/frontend/src/lib/auth/session.ts new file mode 100644 index 0000000..f240287 --- /dev/null +++ b/frontend/src/lib/auth/session.ts @@ -0,0 +1,103 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, errorMessage } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; +import i18n from "@/lib/i18n"; +import { applyTheme } from "@/lib/theme"; + +export type SessionInfo = components["schemas"]["SessionOut"]; +export type UserOut = components["schemas"]["UserOut"]; +export type Credentials = components["schemas"]["Credentials"]; + +export const sessionKey = ["session"] as const; +export const instanceKey = ["instance"] as const; + +/** Apply the preferences stored with the account: language and theme follow the person, not the browser. */ +export function applyUserPreferences(user: UserOut): void { + if (user.language !== i18n.resolvedLanguage) void i18n.changeLanguage(user.language); + applyTheme(user.theme); +} + +export function useInstance() { + return useQuery({ + queryKey: instanceKey, + queryFn: async () => { + const { data, error } = await api.GET("/api/auth/instance"); + if (!data) throw new Error(errorMessage(error, "The server did not answer.")); + return data; + }, + staleTime: 60_000, + }); +} + +export function useSession() { + return useQuery({ + queryKey: sessionKey, + queryFn: async (): Promise => { + const { data, response } = await api.GET("/api/auth/session"); + if (response.status === 401) return null; + if (!data) throw new Error("The server did not answer."); + applyUserPreferences(data.user); + return data; + }, + staleTime: 60_000, + retry: false, + }); +} + +function useSessionMutation(request: (credentials: Credentials) => Promise) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: request, + onSuccess: (data) => { + applyUserPreferences(data.user); + queryClient.setQueryData(sessionKey, data); + void queryClient.invalidateQueries({ queryKey: instanceKey }); + }, + }); +} + +export function useLogin() { + return useSessionMutation(async (credentials) => { + const { data, error } = await api.POST("/api/auth/login", { body: credentials }); + if (!data) throw new Error(errorMessage(error, "Wrong username or password.")); + return data; + }); +} + +export function useClaim() { + return useSessionMutation(async (credentials) => { + const { data, error } = await api.POST("/api/auth/claim", { body: credentials }); + if (!data) throw new Error(errorMessage(error, "The instance could not be claimed.")); + return data; + }); +} + +export function useLogout() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async () => { + await api.POST("/api/auth/logout"); + }, + onSettled: () => { + queryClient.setQueryData(sessionKey, null); + queryClient.clear(); + }, + }); +} + +export function useUpdateMe() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: components["schemas"]["UserUpdateMe"]) => { + const { data, error } = await api.PATCH("/api/auth/me", { body }); + if (!data) throw new Error(errorMessage(error, "The change could not be saved.")); + return data; + }, + onSuccess: (user) => { + applyUserPreferences(user); + queryClient.setQueryData(sessionKey, (old: SessionInfo | null | undefined) => + old ? { ...old, user } : old, + ); + }, + }); +} diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index b66cd6d..328b202 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -4,9 +4,59 @@ "tagline": "Because 'I think it was smaller' is not data.", "intendedUse": "neVus is a personal documentation and measurement aid. It does not diagnose, screen for, or assess the risk of any disease." }, - "shell": { - "language": "Language", - "comingSoon": "The body map, your marks and their observations arrive with the first release." + "common": { "working": "Working…", "cancel": "Cancel", "close": "Close", "back": "Back" }, + "shell": { "language": "Language", "navigation": "Main", "persons": "Persons" }, + "languages": { "en": "English", "es": "Spanish" }, + "auth": { + "username": "Username", + "password": "Password", + "repeatPassword": "Repeat the password", + "passwordHint": "At least {{count}} characters. A sentence works well.", + "passwordTooShort": "The password needs at least {{count}} characters.", + "passwordsDiffer": "The two passwords are not the same.", + "claimTitle": "Claim this instance", + "claimIntro": "Nobody has an account yet. The first account becomes the administrator and creates the others; there is no open registration.", + "claimAction": "Create the administrator", + "loginTitle": "Sign in", + "loginAction": "Sign in" }, - "languages": { "en": "English", "es": "Spanish" } + "persons": { + "title": "Persons", + "name": "Name", + "add": "Add a person", + "create": "Create", + "emptyTitle": "No persons yet", + "emptyText": "A person is somebody whose skin marks you keep a record of: you, or someone you look after.", + "role": { "owner": "Owner", "manager": "Manager", "viewer": "Viewer" } + }, + "images": { + "addPhoto": "Add a photo", + "role": "Kind", + "roles": { + "close_up": "Close-up", + "with_reference": "With reference card", + "overview": "Overview", + "other": "Other" + }, + "takePhoto": "Take a photo", + "chooseFile": "Choose a file", + "uploading": "Uploading…", + "uploaded": "Saved. Location data and device identifiers were removed from the file.", + "privacyNote": "The photo stays on this server. Everything the camera embedded except the capture time and orientation is removed before it is stored.", + "emptyTitle": "No photos yet", + "emptyTextEdit": "Take the first photo of a mark, or choose one from the gallery.", + "emptyTextView": "Nobody has added photos for this person yet.", + "open": "Open the photo", + "alt": "{{role}}, {{date}}", + "noDate": "No capture date", + "delete": "Move to trash" + }, + "settings": { + "title": "Settings", + "theme": "Theme", + "themes": { "system": "Follow the device", "light": "Light", "dark": "Dark" }, + "showUncertainty": "Show measurement uncertainty (±)", + "roles": { "admin": "administrator", "member": "member" }, + "signOut": "Sign out" + } } diff --git a/frontend/src/lib/i18n/locales/es.json b/frontend/src/lib/i18n/locales/es.json index d63d2f5..93f5d34 100644 --- a/frontend/src/lib/i18n/locales/es.json +++ b/frontend/src/lib/i18n/locales/es.json @@ -4,9 +4,59 @@ "tagline": "Porque «creo que era más pequeño» no es un dato.", "intendedUse": "neVus es una ayuda personal de documentación y medición. No diagnostica, no criba ni evalúa el riesgo de ninguna enfermedad." }, - "shell": { - "language": "Idioma", - "comingSoon": "El mapa corporal, tus marcas y sus observaciones llegan con la primera versión." + "common": { "working": "Un momento…", "cancel": "Cancelar", "close": "Cerrar", "back": "Volver" }, + "shell": { "language": "Idioma", "navigation": "Principal", "persons": "Personas" }, + "languages": { "en": "Inglés", "es": "Español" }, + "auth": { + "username": "Usuario", + "password": "Contraseña", + "repeatPassword": "Repite la contraseña", + "passwordHint": "Al menos {{count}} caracteres. Una frase funciona bien.", + "passwordTooShort": "La contraseña necesita al menos {{count}} caracteres.", + "passwordsDiffer": "Las dos contraseñas no coinciden.", + "claimTitle": "Reclamar esta instancia", + "claimIntro": "Todavía nadie tiene cuenta. La primera cuenta será la administradora y creará las demás; no hay registro abierto.", + "claimAction": "Crear la cuenta administradora", + "loginTitle": "Iniciar sesión", + "loginAction": "Entrar" }, - "languages": { "en": "Inglés", "es": "Español" } + "persons": { + "title": "Personas", + "name": "Nombre", + "add": "Añadir una persona", + "create": "Crear", + "emptyTitle": "Todavía no hay personas", + "emptyText": "Una persona es alguien de cuyas marcas en la piel llevas registro: tú o alguien a tu cargo.", + "role": { "owner": "Propietario", "manager": "Gestor", "viewer": "Lector" } + }, + "images": { + "addPhoto": "Añadir una foto", + "role": "Tipo", + "roles": { + "close_up": "Primer plano", + "with_reference": "Con tarjeta de referencia", + "overview": "Vista general", + "other": "Otra" + }, + "takePhoto": "Hacer una foto", + "chooseFile": "Elegir un archivo", + "uploading": "Subiendo…", + "uploaded": "Guardada. Se han eliminado del archivo la ubicación y los identificadores del dispositivo.", + "privacyNote": "La foto se queda en este servidor. Antes de guardarla se elimina todo lo que incrustó la cámara, salvo la fecha de captura y la orientación.", + "emptyTitle": "Todavía no hay fotos", + "emptyTextEdit": "Haz la primera foto de una marca o elige una de la galería.", + "emptyTextView": "Nadie ha añadido fotos de esta persona todavía.", + "open": "Abrir la foto", + "alt": "{{role}}, {{date}}", + "noDate": "Sin fecha de captura", + "delete": "Mover a la papelera" + }, + "settings": { + "title": "Ajustes", + "theme": "Tema", + "themes": { "system": "Como el dispositivo", "light": "Claro", "dark": "Oscuro" }, + "showUncertainty": "Mostrar la incertidumbre de las medidas (±)", + "roles": { "admin": "administración", "member": "miembro" }, + "signOut": "Cerrar sesión" + } } diff --git a/frontend/src/lib/images/index.ts b/frontend/src/lib/images/index.ts new file mode 100644 index 0000000..8966e75 --- /dev/null +++ b/frontend/src/lib/images/index.ts @@ -0,0 +1,60 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, errorMessage } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; + +export type ImageOut = components["schemas"]["ImageOut"]; +export type ImageRole = "overview" | "close_up" | "with_reference" | "other"; + +export const imagesKey = (personId: string) => ["persons", personId, "images"] as const; + +export function useImages(personId: string) { + return useQuery({ + queryKey: imagesKey(personId), + queryFn: async () => { + const { data, error } = await api.GET("/api/persons/{person_id}/images", { + params: { path: { person_id: personId } }, + }); + if (!data) throw new Error(errorMessage(error, "The photos could not be loaded.")); + return data; + }, + }); +} + +export function imageUrl(imageId: string, kind: "original" | "full" | "preview" | "thumb"): string { + return `/api/images/${imageId}/${kind}`; +} + +export function useUploadImage(personId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async ({ file, role }: { file: File; role: ImageRole }) => { + const body = new FormData(); + body.append("file", file, file.name); + body.append("role", role); + body.append("modality", "camera"); + body.append("captured_tz", Intl.DateTimeFormat().resolvedOptions().timeZone); + const response = await fetch(`/api/persons/${personId}/images`, { + method: "POST", + body, + credentials: "same-origin", + }); + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) throw new Error(errorMessage(payload, "The photo could not be uploaded.")); + return payload as ImageOut; + }, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: imagesKey(personId) }), + }); +} + +export function useDeleteImage(personId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (imageId: string) => { + const { error, response } = await api.DELETE("/api/images/{image_id}", { + params: { path: { image_id: imageId } }, + }); + if (!response.ok) throw new Error(errorMessage(error, "The photo could not be deleted.")); + }, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: imagesKey(personId) }), + }); +} diff --git a/frontend/src/lib/persons/index.ts b/frontend/src/lib/persons/index.ts new file mode 100644 index 0000000..ceb674d --- /dev/null +++ b/frontend/src/lib/persons/index.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, errorMessage } from "@/lib/api/client"; +import type { components } from "@/lib/api/schema"; + +export type PersonOut = components["schemas"]["PersonOut"]; +export type PersonIn = components["schemas"]["PersonIn"]; + +export const personsKey = ["persons"] as const; + +export function usePersons() { + return useQuery({ + queryKey: personsKey, + queryFn: async () => { + const { data, error } = await api.GET("/api/persons"); + if (!data) throw new Error(errorMessage(error, "The persons could not be loaded.")); + return data; + }, + }); +} + +export function usePerson(personId: string) { + return useQuery({ + queryKey: [...personsKey, personId], + queryFn: async () => { + const { data, error } = await api.GET("/api/persons/{person_id}", { + params: { path: { person_id: personId } }, + }); + if (!data) throw new Error(errorMessage(error, "This person could not be loaded.")); + return data; + }, + }); +} + +export function useCreatePerson() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (body: PersonIn) => { + const { data, error } = await api.POST("/api/persons", { body }); + if (!data) throw new Error(errorMessage(error, "The person could not be created.")); + return data; + }, + onSuccess: () => void queryClient.invalidateQueries({ queryKey: personsKey }), + }); +} diff --git a/frontend/src/lib/theme/index.ts b/frontend/src/lib/theme/index.ts new file mode 100644 index 0000000..0874560 --- /dev/null +++ b/frontend/src/lib/theme/index.ts @@ -0,0 +1,8 @@ +export type Theme = "system" | "light" | "dark"; + +/** Themes are tokens on the root element; the brand block stays dark regardless (see tokens.css). */ +export function applyTheme(theme: string): void { + const root = document.documentElement; + if (theme === "light" || theme === "dark") root.dataset.theme = theme; + else delete root.dataset.theme; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index a3be158..5ec4ff3 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -6,7 +6,9 @@ import "@/design-system/tokens/tokens.css"; import "@/design-system/base.css"; import { App } from "@/app/App"; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, +}); const root = document.getElementById("root"); if (!root) { throw new Error("Root element missing"); diff --git a/frontend/tests/app.test.tsx b/frontend/tests/app.test.tsx index 25e24c2..42987a3 100644 --- a/frontend/tests/app.test.tsx +++ b/frontend/tests/app.test.tsx @@ -1,18 +1,92 @@ -import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AppRoutes } from "@/app/App"; import i18n from "@/lib/i18n"; -import { App } from "@/app/App"; +import { installMockApi } from "./mockApi"; -describe("App shell", () => { - it("shows the wordmark and the intended-use statement in English", async () => { +function renderApp(path = "/") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + , + ); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("claiming and signing in", () => { + it("sends a visitor of a fresh instance to the claim page and creates the administrator", async () => { + const state = installMockApi({ claimed: false }); await i18n.changeLanguage("en"); - render(); - expect(screen.getByRole("img", { name: "neVus" })).toBeInTheDocument(); - expect(screen.getByText(/does not diagnose/)).toBeInTheDocument(); + renderApp("/"); + expect(await screen.findByRole("heading", { name: "Claim this instance" })).toBeInTheDocument(); + const user = userEvent.setup(); + await user.type(screen.getByLabelText("Username"), "Jose"); + await user.type(screen.getByLabelText("Password"), "correct horse battery"); + await user.type(screen.getByLabelText("Repeat the password"), "correct horse battery"); + await user.click(screen.getByRole("button", { name: "Create the administrator" })); + expect(await screen.findByRole("heading", { name: "Persons" })).toBeInTheDocument(); + expect(state.calls.some((c) => c.method === "POST" && c.url.endsWith("/api/auth/claim"))).toBe(true); + expect(state.session?.username).toBe("jose"); }); - it("switches to Spanish", async () => { - await i18n.changeLanguage("es"); - render(); - expect(screen.getByText(/No diagnostica/)).toBeInTheDocument(); + it("shows the server's message on a wrong password", async () => { + installMockApi({ claimed: true }); + await i18n.changeLanguage("en"); + renderApp("/login"); + const user = userEvent.setup(); + await user.type(await screen.findByLabelText("Username"), "jose"); + await user.type(screen.getByLabelText("Password"), "not the right password"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Wrong username or password."); + }); + + it("refuses passwords shorter than ten characters before asking the server", async () => { + const state = installMockApi({ claimed: true }); + await i18n.changeLanguage("en"); + renderApp("/login"); + const user = userEvent.setup(); + await user.type(await screen.findByLabelText("Username"), "jose"); + await user.type(screen.getByLabelText("Password"), "short"); + await user.click(screen.getByRole("button", { name: "Sign in" })); + expect(await screen.findByRole("alert")).toHaveTextContent("at least 10 characters"); + expect(state.calls.some((c) => c.url.endsWith("/api/auth/login"))).toBe(false); + }); +}); + +describe("persons", () => { + it("lists the persons the user may see and creates a new one", async () => { + const state = installMockApi({ + claimed: true, + session: { username: "jose", language: "en", theme: "system" }, + persons: [{ id: "0199a000-0000-7000-8000-000000000002", display_name: "Ana" }], + }); + await i18n.changeLanguage("en"); + renderApp("/"); + expect(await screen.findByText("Ana")).toBeInTheDocument(); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Add a person" })); + await user.type(screen.getByLabelText("Name"), "Bea"); + await user.click(screen.getByRole("button", { name: "Create" })); + await waitFor(() => expect(state.persons.map((p) => p.display_name)).toEqual(["Ana", "Bea"])); + expect(await screen.findByText("Bea")).toBeInTheDocument(); + }); + + it("applies the language stored with the account", async () => { + installMockApi({ + claimed: true, + session: { username: "jose", language: "es", theme: "dark" }, + persons: [], + }); + await i18n.changeLanguage("en"); + renderApp("/"); + expect(await screen.findByRole("heading", { name: "Personas" })).toBeInTheDocument(); + expect(document.documentElement.dataset.theme).toBe("dark"); }); }); diff --git a/frontend/tests/mockApi.ts b/frontend/tests/mockApi.ts new file mode 100644 index 0000000..47f0d5d --- /dev/null +++ b/frontend/tests/mockApi.ts @@ -0,0 +1,112 @@ +/** A tiny in-memory backend for component tests, speaking the same JSON as the real API. */ +import { vi } from "vitest"; + +export interface MockState { + claimed: boolean; + session: { username: string; language: string; theme: string } | null; + persons: { id: string; display_name: string }[]; + calls: { method: string; url: string; body?: unknown }[]; +} + +const user = (username: string, language = "en", theme = "system") => ({ + id: "0199a000-0000-7000-8000-000000000001", + username, + email: null, + role: "admin", + language, + theme, + show_uncertainty: true, + created_at: "2026-09-23T10:00:00Z", + last_login_at: null, + disabled_at: null, +}); + +export function installMockApi(initial: Partial = {}): MockState { + const state: MockState = { claimed: false, session: null, persons: [], calls: [], ...initial }; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = + input instanceof Request ? input : new Request(new URL(String(input), window.location.origin), init); + const url = request.url; + const method = request.method; + const text = method === "GET" || method === "HEAD" ? "" : await request.text(); + const body = text ? (JSON.parse(text) as unknown) : undefined; + state.calls.push({ method, url, body }); + const path = url.replace(/^https?:\/\/[^/]+/, ""); + if (path === "/api/auth/instance") return json({ claimed: state.claimed, version: "test" }); + if (path === "/api/auth/session") { + return state.session + ? json({ + user: user(state.session.username, state.session.language, state.session.theme), + sudo_until: null, + }) + : json({ detail: "Sign in to continue." }, 401); + } + if (path === "/api/auth/claim" && method === "POST") { + const creds = body as { username: string; password: string }; + state.claimed = true; + state.session = { username: creds.username.toLowerCase(), language: "en", theme: "system" }; + return json({ user: user(state.session.username), sudo_until: null }, 201); + } + if (path === "/api/auth/login" && method === "POST") { + const creds = body as { username: string; password: string }; + if (creds.password !== "correct horse battery") + return json({ detail: "Wrong username or password." }, 401); + state.session = { username: creds.username.toLowerCase(), language: "en", theme: "system" }; + return json({ user: user(state.session.username), sudo_until: null }); + } + if (path === "/api/auth/logout") { + state.session = null; + return new Response(null, { status: 204 }); + } + if (path === "/api/auth/me" && method === "PATCH") { + const patch = body as { language?: string; theme?: string }; + if (state.session) { + state.session.language = patch.language ?? state.session.language; + state.session.theme = patch.theme ?? state.session.theme; + return json(user(state.session.username, state.session.language, state.session.theme)); + } + } + if (path === "/api/persons" && method === "GET") { + return json( + state.persons.map((p) => ({ + ...p, + birth_year: null, + skin_tone: null, + owner_user_id: "0199a000-0000-7000-8000-000000000001", + experimental_analysis: false, + created_at: "2026-09-23T10:00:00Z", + updated_at: "2026-09-23T10:00:00Z", + my_role: "owner", + })), + ); + } + if (path === "/api/persons" && method === "POST") { + const person = body as { display_name: string }; + const created = { + id: `0199a000-0000-7000-8000-00000000000${state.persons.length + 2}`, + display_name: person.display_name, + }; + state.persons.push(created); + return json( + { + ...created, + birth_year: null, + skin_tone: null, + owner_user_id: "x", + experimental_analysis: false, + created_at: "", + updated_at: "", + my_role: "owner", + }, + 201, + ); + } + return json({ detail: `unmocked ${method} ${path}` }, 404); + }), + ); + return state; +}