From e1f4edacfbce3c72fbe5b929dd05288d2457b372 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 6 Oct 2026 23:53:35 +0100 Subject: [PATCH 01/11] feat(auth): add MCDI environment variables and configuration --- .env.example | 9 ++++++++- .env.staging.example | 9 ++++++++- app/core/config.py | 8 ++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index f9b58d7..4969b86 100644 --- a/.env.example +++ b/.env.example @@ -68,4 +68,11 @@ FIREBASE_CREDENTIALS_PATH=path/to/firebase-credentials.json # Resend Email Configuration RESEND_API_KEY= -EMAIL_FROM= \ No newline at end of file +EMAIL_FROM= +# MCDI Configuration +MCDI_PROJECT_ID="your_mcdi_project_id" +MCDI_API_KEY="your_mcdi_api_key" +MCDI_BASE_URL="https://mcdi.microclub.info/api" +MCDI_SERVER_ID="900000000000000001" +MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/api/v1/auth/mcdi/callback" +MCDI_REDIRECT_URI_MOBILE="multai://auth/callback" diff --git a/.env.staging.example b/.env.staging.example index ff88733..a52da79 100644 --- a/.env.staging.example +++ b/.env.staging.example @@ -51,4 +51,11 @@ GOOGLE_OAUTH_SCOPES=https://www.googleapis.com/auth/drive.readonly openid email FACE_ENCRYPTION_KEY=base64-encoded-32-byte-key -FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json \ No newline at end of file +FIREBASE_CREDENTIALS_PATH=/app/firebase-credentials.json +# MCDI Configuration +MCDI_PROJECT_ID="your_mcdi_project_id" +MCDI_API_KEY="your_mcdi_api_key" +MCDI_BASE_URL="https://mcdi.microclub.info/api" +MCDI_SERVER_ID="900000000000000001" +MCDI_REDIRECT_URI_WEB="https://multai.duckdns.org/api/v1/auth/mcdi/callback" +MCDI_REDIRECT_URI_MOBILE="multai://auth/callback" diff --git a/app/core/config.py b/app/core/config.py index bfa4656..dfc4548 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -125,6 +125,14 @@ class Settings(BaseSettings): RESEND_API_KEY: str = "" EMAIL_FROM: str = "onboarding@resend.dev" + # MCDI Configuration + MCDI_PROJECT_ID: str = "" + MCDI_API_KEY: str = "" + MCDI_BASE_URL: str = "https://mcdi.microclub.info/api" + MCDI_SERVER_ID: str = "" + MCDI_REDIRECT_URI_WEB: str = "" + MCDI_REDIRECT_URI_MOBILE: str = "" + model_config = SettingsConfigDict( env_file=".env", extra="ignore", From 17c6ab35c2300db47d112f1743bc407213d2ae0a Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Tue, 6 Oct 2026 23:59:45 +0100 Subject: [PATCH 02/11] feat(db): add discord_id to users table --- db/generated/models.py | 1 + db/generated/user.py | 63 ++++++++++++++----- db/queries/user.sql | 9 ++- .../sql/down/add_discord_id_to_users.sql | 2 + migrations/sql/up/add_discord_id_to_users.sql | 2 + .../27efe448f970_add_discord_id_to_users.py | 27 ++++++++ 6 files changed, 88 insertions(+), 16 deletions(-) create mode 100644 migrations/sql/down/add_discord_id_to_users.sql create mode 100644 migrations/sql/up/add_discord_id_to_users.sql create mode 100644 migrations/versions/27efe448f970_add_discord_id_to_users.py diff --git a/db/generated/models.py b/db/generated/models.py index adf1f28..eac8094 100644 --- a/db/generated/models.py +++ b/db/generated/models.py @@ -266,6 +266,7 @@ class User: deleted_at: Optional[datetime.datetime] blocked: bool avatar_key: Optional[str] + discord_id: Optional[str] @dataclasses.dataclass() diff --git a/db/generated/user.py b/db/generated/user.py index e922f86..6fc8d7e 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -13,9 +13,9 @@ CREATE_USER = """-- name: create_user \\:one -INSERT INTO users (email, hashed_password) -VALUES (:p1, :p2) -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +INSERT INTO users (email, hashed_password, discord_id) +VALUES (:p1, :p2, :p3) +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -41,22 +41,29 @@ class FindClosestUserByEmbeddingRow: distance: Optional[Any] +GET_USER_BY_DISCORD_ID = """-- name: get_user_by_discord_id \\:one +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id +FROM users +WHERE discord_id = :p1 +""" + + GET_USER_BY_EMAIL = """-- name: get_user_by_email \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE email = :p1 """ GET_USER_BY_ID = """-- name: get_user_by_id \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE id = :p1 """ GET_USER_BY_ID_FOR_UPDATE = """-- name: get_user_by_id_for_update \\:one -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users WHERE id = :p1 FOR UPDATE @@ -64,7 +71,7 @@ class FindClosestUserByEmbeddingRow: LIST_USERS = """-- name: list_users \\:many -SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +SELECT id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id FROM users ORDER BY created_at DESC LIMIT :p1 OFFSET :p2 @@ -90,7 +97,7 @@ class ListUsersWithEmbeddingRow: SET blocked = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -99,7 +106,7 @@ class ListUsersWithEmbeddingRow: SET face_embedding = :p1\\:\\:vector, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -110,7 +117,7 @@ class ListUsersWithEmbeddingRow: blocked = COALESCE(:p3, blocked), updated_at = NOW() WHERE id = :p4 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -119,7 +126,7 @@ class ListUsersWithEmbeddingRow: SET avatar_key = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -128,7 +135,7 @@ class ListUsersWithEmbeddingRow: SET hashed_password = :p1, updated_at = NOW() WHERE id = :p2 -RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id """ @@ -136,8 +143,8 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Optional[models.User]: - row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password})).first() + async def create_user(self, *, email: str, hashed_password: Optional[str], discord_id: Optional[str]) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password, "p3": discord_id})).first() if row is None: return None return models.User( @@ -151,6 +158,7 @@ async def create_user(self, *, email: str, hashed_password: Optional[str]) -> Op deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def delete_user(self, *, id: uuid.UUID) -> None: @@ -165,6 +173,24 @@ async def find_closest_user_by_embedding(self, *, dollar_1: Any) -> Optional[Fin distance=row[1], ) + async def get_user_by_discord_id(self, *, discord_id: Optional[str]) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_DISCORD_ID), {"p1": discord_id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + avatar_key=row[9], + discord_id=row[10], + ) + async def get_user_by_email(self, *, email: str) -> Optional[models.User]: row = (await self._conn.execute(sqlalchemy.text(GET_USER_BY_EMAIL), {"p1": email})).first() if row is None: @@ -180,6 +206,7 @@ async def get_user_by_email(self, *, email: str) -> Optional[models.User]: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -197,6 +224,7 @@ async def get_user_by_id(self, *, id: uuid.UUID) -> Optional[models.User]: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.User]: @@ -214,6 +242,7 @@ async def get_user_by_id_for_update(self, *, id: uuid.UUID) -> Optional[models.U deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.User]: @@ -230,6 +259,7 @@ async def list_users(self, *, limit: int, offset: int) -> AsyncIterator[models.U deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def list_users_with_embedding(self) -> AsyncIterator[ListUsersWithEmbeddingRow]: @@ -255,6 +285,7 @@ async def set_user_blocked(self, *, blocked: bool, id: uuid.UUID) -> Optional[mo deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[models.User]: @@ -272,6 +303,7 @@ async def set_user_embedding(self, *, dollar_1: Any, id: uuid.UUID) -> Optional[ deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user(self, *, email: str, display_name: Optional[str], blocked: bool, id: uuid.UUID) -> Optional[models.User]: @@ -294,6 +326,7 @@ async def update_user(self, *, email: str, display_name: Optional[str], blocked: deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -311,6 +344,7 @@ async def update_user_avatar(self, *, avatar_key: Optional[str], id: uuid.UUID) deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) async def update_user_password(self, *, hashed_password: Optional[str], id: uuid.UUID) -> Optional[models.User]: @@ -328,4 +362,5 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid deleted_at=row[7], blocked=row[8], avatar_key=row[9], + discord_id=row[10], ) diff --git a/db/queries/user.sql b/db/queries/user.sql index 61c7c59..e1d0b88 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -1,6 +1,6 @@ -- name: CreateUser :one -INSERT INTO users (email, hashed_password) -VALUES ($1, $2) +INSERT INTO users (email, hashed_password, discord_id) +VALUES ($1, $2, $3) RETURNING *; -- name: GetUserById :one @@ -19,6 +19,11 @@ SELECT * FROM users WHERE email = $1; +-- name: GetUserByDiscordId :one +SELECT * +FROM users +WHERE discord_id = $1; + -- name: UpdateUserPassword :one UPDATE users SET hashed_password = $1, diff --git a/migrations/sql/down/add_discord_id_to_users.sql b/migrations/sql/down/add_discord_id_to_users.sql new file mode 100644 index 0000000..6592b63 --- /dev/null +++ b/migrations/sql/down/add_discord_id_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users DROP COLUMN IF EXISTS discord_id; +ALTER TABLE users ALTER COLUMN hashed_password SET NOT NULL; diff --git a/migrations/sql/up/add_discord_id_to_users.sql b/migrations/sql/up/add_discord_id_to_users.sql new file mode 100644 index 0000000..e39d1e2 --- /dev/null +++ b/migrations/sql/up/add_discord_id_to_users.sql @@ -0,0 +1,2 @@ +ALTER TABLE users ADD COLUMN discord_id VARCHAR(255) UNIQUE; +ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL; diff --git a/migrations/versions/27efe448f970_add_discord_id_to_users.py b/migrations/versions/27efe448f970_add_discord_id_to_users.py new file mode 100644 index 0000000..c3bf277 --- /dev/null +++ b/migrations/versions/27efe448f970_add_discord_id_to_users.py @@ -0,0 +1,27 @@ +"""add_discord_id_to_users + +Revision ID: 27efe448f970 +Revises: 7eb80a1e711a +Create Date: 2026-10-06 23:57:24.958872 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from migrations.helper import run_sql_up, run_sql_down + + +# revision identifiers, used by Alembic. +revision: str = '27efe448f970' +down_revision: Union[str, Sequence[str], None] = '7eb80a1e711a' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + run_sql_up("add_discord_id_to_users") + + +def downgrade() -> None: + run_sql_down("add_discord_id_to_users") From 1b00176d4633dc97bed20c691ccf8b1494735e53 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:05:32 +0100 Subject: [PATCH 03/11] feat(mcdi): implement McdiService client --- app/container.py | 3 +++ app/service/mcdi.py | 55 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + uv.lock | 2 ++ 4 files changed, 61 insertions(+) create mode 100644 app/service/mcdi.py diff --git a/app/container.py b/app/container.py index 0024ea6..cb2ccb6 100644 --- a/app/container.py +++ b/app/container.py @@ -18,6 +18,7 @@ from app.service.upload_requests import UploadRequestsService from app.service.users import AuthService from app.service.user_notification import UserNotificationService +from app.service.mcdi import McdiService from db.generated import devices as device_queries from db.generated import photo_approvals as photo_approval_queries from db.generated import processing_jobs as processing_job_queries @@ -162,6 +163,8 @@ def __init__( querier=self.stats_querier, ) + self.mcdi_service = McdiService() + async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), diff --git a/app/service/mcdi.py b/app/service/mcdi.py new file mode 100644 index 0000000..43ad4c9 --- /dev/null +++ b/app/service/mcdi.py @@ -0,0 +1,55 @@ +import httpx +from typing import Any +from app.core.config import settings +from app.core.exceptions import AppException + + +class McdiService: + def __init__(self) -> None: + self.base_url = settings.MCDI_BASE_URL.rstrip("/") + self.api_key = settings.MCDI_API_KEY + self.client_id = settings.MCDI_PROJECT_ID + + async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: + """Exchanges an OAuth code for a session token and member info.""" + url = f"{self.base_url}/auth/token" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = { + "clientId": self.client_id, + "code": code, + "redirectUri": redirect_uri, + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + # MCDI standardizes errors + error_detail = response.json().get("message", "Failed to exchange MCDI code") + raise AppException.unauthorized(f"MCDI Error: {error_detail}") + return response.json() + except httpx.RequestError as e: + raise AppException.internal_error(f"MCDI service unavailable: {e}") + + async def validate_token(self, token: str) -> dict[str, Any]: + """Validates an MCDI session token and fetches live roles.""" + url = f"{self.base_url}/auth/validate" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = { + "token": token + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + raise AppException.unauthorized("Invalid or expired MCDI session") + return response.json() + except httpx.RequestError as e: + raise AppException.internal_error(f"MCDI service unavailable: {e}") diff --git a/pyproject.toml b/pyproject.toml index 89d4ad8..9ceac35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "pillow-heif>=1.3.0", "sqlalchemy>=2.0.47", "setuptools>=83.0.0", + "httpx>=0.28.1", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index b403b75..835c8cf 100644 --- a/uv.lock +++ b/uv.lock @@ -308,6 +308,7 @@ dependencies = [ { name = "filetype" }, { name = "firebase-admin" }, { name = "greenlet" }, + { name = "httpx" }, { name = "insightface" }, { name = "miniopy-async" }, { name = "nats-py" }, @@ -347,6 +348,7 @@ requires-dist = [ { name = "filetype", specifier = ">=1.2.0" }, { name = "firebase-admin", specifier = ">=6.8.0" }, { name = "greenlet", specifier = ">=3.3.2" }, + { name = "httpx", specifier = ">=0.28.1" }, { name = "insightface", specifier = ">=0.7.3" }, { name = "miniopy-async", specifier = ">=1.23.4" }, { name = "nats-py", specifier = ">=2.14.0" }, From 0ab5fd459ccbf5b76170c5cd8e93b3d4d0176855 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:08:15 +0100 Subject: [PATCH 04/11] refactor(auth-web): migrate staff login to MCDI SSO --- app/deps/cookie_auth.py | 40 ++++++++---------- app/router/web/auth.py | 87 ++++++++++++++++++++++++++++----------- app/service/staff_user.py | 21 ---------- 3 files changed, 81 insertions(+), 67 deletions(-) diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 456fe68..1a8eaf6 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -1,11 +1,8 @@ from typing import Annotated -import uuid - from fastapi import Cookie, Depends from app.container import Container, get_container from app.core.exceptions import AppException from db.generated.models import StaffRole, StaffUser -from app.core.securite import decode_staff_token def _role_value(role: object) -> str: @@ -18,26 +15,23 @@ async def get_current_staff_user( ) -> StaffUser: if token is None: raise AppException.unauthorized("token doestn exist") - else: - payload = decode_staff_token(token) - staff_id_str = payload.sub - - if not staff_id_str: - raise AppException.unauthorized("Token missing subject") - - # 2. Convert to UUID and fetch user from DB - try: - staff_id = uuid.UUID(staff_id_str) - except ValueError: - raise AppException.unauthorized("Invalid staff ID in token") - - staff_user = await container.staff_user_querier.get_staff_user_by_id( - id=staff_id - ) - if staff_user is None: - raise AppException.not_found("Staff user not found") - - return staff_user + + # 1. Validate token with MCDI (live verification) + try: + mcdi_data = await container.mcdi_service.validate_token(token) + except Exception: + raise AppException.unauthorized("MCDI session invalid or expired") + + email = mcdi_data.get("member", {}).get("email") + if not email: + raise AppException.unauthorized("Invalid MCDI payload: missing email") + + # 2. Get local StaffUser by email + staff_user = await container.staff_user_querier.get_staff_user_by_email(email=email) + if staff_user is None: + raise AppException.unauthorized("Staff user not found or not authorized") + + return staff_user def ensure_multi_team_lead_staff(current_staff_user: StaffUser) -> StaffUser: diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 76f945b..c9ef6ca 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,42 +1,80 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request, Cookie from app.container import Container, get_container from fastapi import Response +from fastapi.responses import RedirectResponse from app.core.config import settings from app.deps.cookie_auth import get_current_staff_user -from app.deps.rate_limit import RateLimiter -from app.schema.request.web.auth import WebAuthRequest -from app.schema.response.web.auth import WebAuthResponse from app.schema.response.web.staff_user import StaffUserSchema from db.generated.models import StaffUser +from app.core.exceptions import AppException +import urllib.parse +import secrets router = APIRouter(prefix="/auth") -@router.post( - "/login", - response_model=WebAuthResponse, - description="so here both the dahbsoard will authneticate from this endpoitn ", - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def admin_login( - req: WebAuthRequest, +@router.get("/mcdi/login") +async def mcdi_login(r: Response) -> RedirectResponse: + state = secrets.token_urlsafe(16) + r.set_cookie( + "mcdi_state", + state, + httponly=True, + max_age=300, + secure=settings.environment != "dev", + samesite="lax" + ) + params = urllib.parse.urlencode({ + "client_id": settings.MCDI_PROJECT_ID, + "redirect_uri": settings.MCDI_REDIRECT_URI_WEB, + "state": state + }) + url = f"{settings.MCDI_BASE_URL.replace('/api', '')}/api/auth/sso/authorize?{params}" + return RedirectResponse(url) + + +@router.get("/mcdi/callback") +async def mcdi_callback( + request: Request, r: Response, + code: str, + state: str, container: Container = Depends(get_container), -) -> WebAuthResponse: +) -> RedirectResponse: + cookie_state = request.cookies.get("mcdi_state") + if not cookie_state or state != cookie_state: + raise AppException.unauthorized("Invalid state parameter") - authResponse = await container.staff_user_service.admin_login( - email=req.email, password=req.password - ) - r.set_cookie( + mcdi_data = await container.mcdi_service.exchange_code(code, settings.MCDI_REDIRECT_URI_WEB) + + # Extract email and verify staff access + email = mcdi_data.get("member", {}).get("email") + if not email: + raise AppException.unauthorized("No email provided by MCDI") + + staff_user = await container.staff_user_querier.get_staff_user_by_email(email=email) + if not staff_user: + raise AppException.unauthorized("Not authorized for admin panel") + + token = mcdi_data.get("token") + if not token: + raise AppException.internal_error("MCDI did not return a session token") + + # Let the frontend dashboard URL be determined by CORS origin or environment + frontend_url = settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173" + + redirect_res = RedirectResponse(url=f"{frontend_url}/dashboard") + redirect_res.set_cookie( key="access_token", - value=authResponse.access_token, + value=token, httponly=True, secure=settings.environment != "dev", - samesite="strict", + samesite="lax", max_age=60 * 60 * 24 * 7, ) - return authResponse + redirect_res.delete_cookie("mcdi_state") + return redirect_res @router.get("/me", response_model=StaffUserSchema) @@ -55,11 +93,14 @@ async def get_me_admin( @router.post("/logout", status_code=204) async def admin_logout( r: Response, - _: StaffUser = Depends(get_current_staff_user), + token: str | None = Cookie(default=None, alias="access_token"), + container: Container = Depends(get_container), ) -> None: + # We could also call MCDI logout endpoint here to kill the session globally or for this project + # But removing the cookie is the minimum required. r.delete_cookie( key="access_token", httponly=True, - secure=True, - samesite="strict", + secure=settings.environment != "dev", + samesite="lax", ) diff --git a/app/service/staff_user.py b/app/service/staff_user.py index b72cd89..ace9644 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -101,28 +101,7 @@ async def list_staff_users( logger.error("Failed to list staff users: %s", exc) raise DBException.handle(exc) - async def admin_login( - self, - email: str, - password: str, - ) -> WebAuthResponse: - normalized_email = email.strip().lower() - staff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_email( - email=normalized_email - ) - if staff is None or not verify_password(password, staff.password): - logger.info("admin login failed for email %s", normalized_email) - raise AppException.unauthorized("Invalid email or password") - access_token = create_access_staff_token( - staff_id=str(staff.id), role=staff.role - ) - - return WebAuthResponse( - access_token=access_token, - user_id=staff.id, - role=staff.role, - ) async def get_staff_user(self, stuff_id: uuid.UUID) -> StaffUser: stuff: StaffUser | None = await self.staff_user_querier.get_staff_user_by_id( From c42eca5d78135ebc22ed1924094b0ae20dce9748 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:12:54 +0100 Subject: [PATCH 05/11] refactor(auth-mobile): migrate mobile auth to MCDI SSO --- app/container.py | 5 +- app/deps/token_auth.py | 11 ++ app/router/mobile/auth.py | 94 +++-------- app/schema/request/mobile/auth.py | 53 +++--- app/service/session.py | 3 + app/service/users.py | 257 ++++++------------------------ 6 files changed, 106 insertions(+), 317 deletions(-) diff --git a/app/container.py b/app/container.py index cb2ccb6..726c376 100644 --- a/app/container.py +++ b/app/container.py @@ -87,6 +87,8 @@ def __init__( redis=self.redis, ) + self.mcdi_service = McdiService() + self.device_service = DeviceService() self.device_service.init( device_querier=self.device_querier, @@ -98,6 +100,7 @@ def __init__( session_querier=self.session_querier, refresh_token_querier=self.refresh_token_querier, face_embedding_service=self.face_embedding_service, + mcdi_service=self.mcdi_service, ) self.staff_drive_service = StaffDriveService( @@ -163,8 +166,6 @@ def __init__( querier=self.stats_querier, ) - self.mcdi_service = McdiService() - async def get_container( conn: sqlalchemy.ext.asyncio.AsyncConnection = Depends(get_db), diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 12c6718..b4a30d6 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -52,6 +52,15 @@ async def get_current_mobile_user( if cached.blocked: raise HTTPException(status_code=403, detail="User is blocked") + + # MCDI validation in fast path (async task or inline? We should inline for security if required, but for mobile we can do it inline since redis is fast) + # But wait, MCDI validate_token requires a token. + if cached.mcdi_token: + try: + await container.mcdi_service.validate_token(cached.mcdi_token) + except Exception: + raise HTTPException(status_code=401, detail="MCDI session invalid") + if ( now - cached.last_active ).total_seconds() > settings.SESSION_ACTIVITY_THROTTLE_SECONDS: @@ -73,6 +82,7 @@ async def get_current_mobile_user( blocked=cached.blocked, ttl=settings.MOBILE_SESSION_TTL_SECONDS, last_active=now, + mcdi_token=cached.mcdi_token, ) return MobileUserSchema( @@ -111,6 +121,7 @@ async def get_current_mobile_user( blocked=user.blocked, ttl=settings.MOBILE_SESSION_TTL_SECONDS, last_active=session.last_active, + mcdi_token=None, # MCDI token not available on slow path, will be re-fetched on login ) return MobileUserSchema( diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index 81efcdb..c3b0572 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -13,10 +13,11 @@ from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( - MobileLoginRequest, - MobileRegisterRequest, - RegisterVerifyRequest, - ResendOtpRequest, + McdiExchangeRequest, + + + + RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, @@ -28,96 +29,39 @@ MobileAuthResponse, SessionSchema, UserSchema, - RegisterPendingResponse, ) router = APIRouter(prefix="/auth") @router.post( - "/register", - response_model=RegisterPendingResponse | MobileAuthResponse, + "/mcdi/exchange", + response_model=MobileAuthResponse, dependencies=[Depends(RateLimiter(requests=5, window=60))], ) -async def mobile_register( - req: MobileRegisterRequest, +async def mcdi_exchange( + req: McdiExchangeRequest, request: Request, container: Container = Depends(get_container), -) -> RegisterPendingResponse | MobileAuthResponse: +) -> MobileAuthResponse: client_ip = get_client_ip(request) - result = await container.auth_service.mobile_register( + result = await container.auth_service.mcdi_exchange( container.redis, req, client_ip=client_ip ) - if isinstance(result, MobileAuthResponse): + if result.is_new_user: await container.audit_service.create_record( event_type=AuditEventType.USER_SIGNUP, user_id=result.user_id, - metadata={"endpoint": "register", "otp_activated": False}, + metadata={"endpoint": "mcdi_exchange"}, + ) + else: + await container.audit_service.create_record( + event_type=AuditEventType.USER_LOGIN, + user_id=result.user_id, + metadata={"endpoint": "mcdi_exchange"}, ) return result - -@router.post( - "/register/resend-otp", - response_model=RegisterPendingResponse, - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def mobile_register_resend_otp( - req: ResendOtpRequest, - request: Request, - container: Container = Depends(get_container), -) -> RegisterPendingResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.mobile_register_resend_otp( - container.redis, req.email, client_ip=client_ip - ) - return result - - -@router.post( - "/register/verify", - response_model=MobileAuthResponse, - dependencies=[Depends(RateLimiter(requests=10, window=60))], -) -async def mobile_register_verify( - req: RegisterVerifyRequest, - request: Request, - container: Container = Depends(get_container), -) -> MobileAuthResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.verify_mobile_register( - container.redis, req, client_ip=client_ip - ) - await container.audit_service.create_record( - event_type=AuditEventType.USER_SIGNUP, - user_id=result.user_id, - metadata={"endpoint": "register_verify"}, - ) - return result - - -@router.post( - "/login", - response_model=MobileAuthResponse, - dependencies=[Depends(RateLimiter(requests=5, window=60))], -) -async def mobile_login( - req: MobileLoginRequest, - request: Request, - container: Container = Depends(get_container), -) -> MobileAuthResponse: - client_ip = get_client_ip(request) - result = await container.auth_service.mobile_login( - container.redis, req, client_ip=client_ip - ) - await container.audit_service.create_record( - event_type=AuditEventType.USER_LOGIN, - user_id=result.user_id, - metadata={"endpoint": "login"}, - ) - return result - - @router.post( "/refresh", response_model=MobileAuthResponse, diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 592b947..563bbe6 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -44,43 +44,34 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: return stripped -class MobileRegisterRequest(MobileAuthBaseRequest): - @field_validator("password") - @classmethod - def _validate_password_complexity(cls, value: str) -> str: - if not any(c.isupper() for c in value): - raise ValueError("Password must contain at least one uppercase letter") - if not any(c.islower() for c in value): - raise ValueError("Password must contain at least one lowercase letter") - if not any(c.isdigit() for c in value): - raise ValueError("Password must contain at least one digit") - if not any(not c.isalnum() for c in value): - raise ValueError("Password must contain at least one special character") - return value - - -class MobileLoginRequest(MobileAuthBaseRequest): - pass - - -class RegisterVerifyRequest(MobileAuthBaseRequest): - otp: str = Field( +class McdiExchangeRequest(BaseModel): + code: str + device_name: str = Field( ..., - min_length=6, - max_length=6, - description="The 6-digit OTP code sent via email", + min_length=1, + max_length=settings.MOBILE_AUTH_DEVICE_NAME_MAX_LEN, ) + device_type: str = Field( + ..., + min_length=1, + max_length=settings.MOBILE_AUTH_DEVICE_TYPE_MAX_LEN, + ) + physical_device_id: UUID - -class ResendOtpRequest(BaseModel): - email: EmailStr = Field(..., max_length=255) - - @field_validator("email", mode="before") + @field_validator("device_name", "device_type", mode="before") @classmethod - def _normalize_email(cls, value: object) -> object: + def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: if not isinstance(value, str): return value - return value.strip().lower() + stripped = value.strip() + if not stripped: + raise ValueError(f"{info.field_name} must not be empty") + if info.field_name == "device_type": + return stripped.lower() + return stripped + + + class RefreshTokenRequest(BaseModel): diff --git a/app/service/session.py b/app/service/session.py index 97b980f..c082a68 100644 --- a/app/service/session.py +++ b/app/service/session.py @@ -17,6 +17,7 @@ class MobileSessionCache(BaseModel): absolute_expires_at: datetime blocked: bool last_active: datetime + mcdi_token: str | None = None class SessionService: @@ -39,6 +40,7 @@ async def cache_session_for_auth( blocked: bool, ttl: int, last_active: datetime, + mcdi_token: str | None = None, ) -> None: key = RedisKey.MobileSessionCache.value.format(session_id=session_id) payload = MobileSessionCache( @@ -49,6 +51,7 @@ async def cache_session_for_auth( absolute_expires_at=absolute_expires_at, blocked=blocked, last_active=last_active, + mcdi_token=mcdi_token, ) try: await redis.set(key=key, value=payload.model_dump_json(), expire=ttl) diff --git a/app/service/users.py b/app/service/users.py index d534ff5..e076d28 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,15 +1,14 @@ from datetime import datetime, timedelta, timezone import uuid +import sqlalchemy from collections.abc import AsyncIterable -from typing import Optional +from typing import Optional, Any from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, - verify_password, create_acces_mobile_token, create_raw_refresh_token, hash_refresh_token, @@ -19,16 +18,11 @@ from app.core.config import settings from app.infra.redis import RedisClient from app.infra.minio import Bucket, IMAGES_BUCKET_NAME +from app.service.mcdi import McdiService from app.schema.request.mobile.auth import ( - MobileAuthBaseRequest, - MobileLoginRequest, - MobileRegisterRequest, - RegisterVerifyRequest, + McdiExchangeRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse -from app.infra.nats import NatsClient -import secrets -import json +from app.schema.response.mobile.auth import MobileAuthResponse from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries @@ -56,17 +50,19 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, + mcdi_service: McdiService, ): self.user_querier = user_querier self.device_querier = device_querier self.session_querier = session_querier self.refresh_token_querier = refresh_token_querier self.face_embedding_service = face_embedding_service + self.mcdi_service = mcdi_service async def _ensure_device_for_login( self, user_id: uuid.UUID, - req: MobileAuthBaseRequest, + req: Any, ) -> UserDevice: existing_device = await self.device_querier.get_device_by_physical_id( user_id=user_id, physical_device_id=req.physical_device_id @@ -98,222 +94,62 @@ async def _ensure_device_for_login( raise AppException.internal_error("Failed to create device") return device - async def mobile_login( + async def mcdi_exchange( self, redis: RedisClient, - req: MobileLoginRequest, + req: McdiExchangeRequest, client_ip: Optional[str] = None, ) -> MobileAuthResponse: - logger.info("mobile_login attempt") + logger.info("mcdi_exchange attempt") max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS if client_ip: - await self.check_rate_limit( - redis, - f"rate:ip:{client_ip}", - max_attempts, - window, - ) - await self.check_rate_limit( - redis, - f"rate:email:{req.email}", - max_attempts, - window, - ) + await self.check_rate_limit(redis, f"rate:ip:{client_ip}", max_attempts, window) - existing_user = await self.user_querier.get_user_by_email(email=req.email) - if existing_user is None: - logger.warning("login attempt: user_not_found") - raise AppException.unauthorized( - "User not found; consider registering instead" - ) - if existing_user.blocked: - logger.warning("login attempt: user_blocked user_id=%s", existing_user.id) - raise AppException.forbidden("User is blocked") - if not verify_password(req.password, existing_user.hashed_password or ""): - logger.warning( - "login attempt: invalid_credentials user_id=%s", existing_user.id - ) - raise AppException.unauthorized("Invalid credentials") + mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) - locked_user = await self.user_querier.get_user_by_id_for_update( - id=existing_user.id - ) - if not locked_user: - raise AppException.unauthorized("User not found") - if locked_user.blocked: - logger.warning( - "login attempt: user_blocked_at_commit user_id=%s", locked_user.id - ) - raise AppException.forbidden("User is blocked") + email = mcdi_data.get("member", {}).get("email") + discord_id = mcdi_data.get("member", {}).get("discordId") + mcdi_token = mcdi_data.get("token") - logger.info("login success user_id=%s", locked_user.id) - return await self._create_mobile_session( - redis=redis, - user=locked_user, - req=req, - is_new_user=False, - ) + if not email or not discord_id or not mcdi_token: + raise AppException.unauthorized("Invalid MCDI payload") - async def mobile_register( - self, - redis: RedisClient, - req: MobileRegisterRequest, - client_ip: Optional[str] = None, - ) -> RegisterPendingResponse | MobileAuthResponse: - logger.info("mobile_register attempt") - max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS - window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS - - if client_ip: - await self.check_rate_limit( - redis, - f"rate:ip:{client_ip}", - max_attempts, - window, - ) - await self.check_rate_limit( - redis, - f"rate:email:{req.email}", - max_attempts, - window, - ) - - existing_user = await self.user_querier.get_user_by_email(email=req.email) - if existing_user is not None: - logger.warning("register attempt: email_already_in_use") - raise AppException.conflict("Email already in use; please login instead") - - hashed = hash_password(req.password) - - if not settings.OTP_ACTIVATED: - logger.info("OTP deactivated, creating user directly") - user = await self.user_querier.create_user( - email=req.email, hashed_password=hashed - ) - if not user: - raise AppException.internal_error("Failed to create user") - return await self._create_mobile_session( - redis=redis, - user=user, - req=req, - is_new_user=True, - ) + await self.check_rate_limit(redis, f"rate:email:{email}", max_attempts, window) - pending_key = f"pending_user:{req.email}" - pending_data = { - "hashed_password": hashed, - } - - # Save in Redis for 10 minutes (600 seconds) - await redis.set(pending_key, json.dumps(pending_data), expire=600) - - if settings.environment == "dev": - otp = settings.DEV_OTP_BYPASS_CODE - await redis.set(f"otp:{req.email}", otp, expire=600) - logger.info("dev OTP bypass active, otp=%s email=%s", otp, req.email) - else: - otp = "".join(secrets.choice("0123456789") for _ in range(6)) - await redis.set(f"otp:{req.email}", otp, expire=600) - # Send to NATS - await NatsClient.js_publish( - "email.send_otp", - json.dumps({"email": req.email, "otp": otp}).encode("utf-8"), - ) - - logger.info("register success, OTP sent") - return RegisterPendingResponse( - message="OTP sent to email", status="pending_verification", email=req.email - ) + user = await self.user_querier.get_user_by_discord_id(discord_id=discord_id) + is_new_user = False - async def mobile_register_resend_otp( - self, - redis: RedisClient, - email: str, - client_ip: Optional[str] = None, - ) -> RegisterPendingResponse: - logger.info("resend_otp attempt for %s", email) - max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS - window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS - - if client_ip: - await self.check_rate_limit( - redis, - f"rate:ip:{client_ip}", - max_attempts, - window, - ) - await self.check_rate_limit( - redis, - f"rate:email:{email}", - max_attempts, - window, - ) - - pending_key = f"pending_user:{email}" - raw_data = await redis.get(pending_key) - if not raw_data: - raise AppException.not_found("No pending registration found for this email") - - if settings.environment == "dev": - otp = settings.DEV_OTP_BYPASS_CODE - await redis.set(f"otp:{email}", otp, expire=600) - logger.info("dev OTP bypass active, otp=%s email=%s", otp, email) - else: - otp = "".join(secrets.choice("0123456789") for _ in range(6)) - # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL - await redis.set(f"otp:{email}", otp, expire=600) - # Send to NATS - await NatsClient.js_publish( - "email.send_otp", - json.dumps({"email": email, "otp": otp}).encode("utf-8"), - ) - - logger.info("resend_otp success, new OTP sent to %s", email) - return RegisterPendingResponse( - message="New OTP sent to email", status="pending_verification", email=email - ) - - async def verify_mobile_register( - self, - redis: RedisClient, - req: RegisterVerifyRequest, - client_ip: Optional[str] = None, - ) -> MobileAuthResponse: - otp_key = f"otp:{req.email}" - stored_otp = await redis.get(otp_key) - - if not stored_otp or stored_otp != req.otp: - raise AppException.unauthorized("Invalid or expired OTP") - - pending_key = f"pending_user:{req.email}" - raw_data = await redis.get(pending_key) - if not raw_data: - raise AppException.unauthorized("Registration session expired") - - data = json.loads(raw_data) + if not user: + user = await self.user_querier.get_user_by_email(email=email) + if user: + # Update with discord_id using direct DB execution since generated query doesn't exist + await self.user_querier._conn.execute( + sqlalchemy.text("UPDATE users SET discord_id = :d WHERE id = :id"), + {"d": discord_id, "id": user.id} + ) + else: + user = await self.user_querier.create_user( + email=email, hashed_password=None, discord_id=discord_id + ) + if not user: + raise AppException.internal_error("Failed to create user") + is_new_user = True - try: - user = await self.user_querier.create_user( - email=req.email, hashed_password=data["hashed_password"] - ) - if not user: - raise AppException.internal_error("Failed to create user") - except SQLAlchemyError as exc: - logger.error("Failed to create user: %s", exc) - raise DBException.handle(exc) + if user.blocked: + raise AppException.forbidden("User is blocked") - # Clean up redis - await redis.delete(otp_key) - await redis.delete(pending_key) + locked_user = await self.user_querier.get_user_by_id_for_update(id=user.id) + if not locked_user or locked_user.blocked: + raise AppException.forbidden("User is blocked") - logger.info("register verify success user_id=%s", user.id) return await self._create_mobile_session( redis=redis, - user=user, + user=locked_user, req=req, - is_new_user=True, + is_new_user=is_new_user, + mcdi_token=mcdi_token, ) async def _create_mobile_session( @@ -321,8 +157,9 @@ async def _create_mobile_session( *, redis: RedisClient, user: User, - req: MobileAuthBaseRequest, + req: Any, is_new_user: bool, + mcdi_token: str | None = None, ) -> MobileAuthResponse: user_id: uuid.UUID = user.id @@ -376,6 +213,7 @@ async def _create_mobile_session( blocked=user.blocked, ttl=AuthService.REDIS_SESSION_TTL, last_active=session.last_active, + mcdi_token=mcdi_token, ) return MobileAuthResponse( @@ -582,6 +420,7 @@ async def create_user( user = await self.user_querier.create_user( email=email, hashed_password=hashed, + discord_id=None, ) if not user: raise AppException.internal_error("Failed to create user") From 7faddb4aa43d60cfb2961503d166f3cfdb5987a1 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:37:32 +0100 Subject: [PATCH 06/11] refactor(mcdi): harden OAuth flow, Redis token caching, and spec alignment --- app/deps/cookie_auth.py | 10 +- app/deps/token_auth.py | 144 +++++++++++---------- app/router/web/auth.py | 48 +++---- app/schema/request/mobile/auth.py | 32 +++++ app/service/mcdi.py | 118 ++++++++++++++++-- app/service/staff_user.py | 3 +- app/service/users.py | 23 ++-- db/generated/user.py | 27 ++++ db/queries/user.sql | 7 ++ tests/unit/test_mcdi_auth.py | 201 ++++++++++++++++++++++++++++++ 10 files changed, 499 insertions(+), 114 deletions(-) create mode 100644 tests/unit/test_mcdi_auth.py diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 1a8eaf6..4fe9b1e 100644 --- a/app/deps/cookie_auth.py +++ b/app/deps/cookie_auth.py @@ -14,11 +14,15 @@ async def get_current_staff_user( token: Annotated[str | None, Cookie(alias="access_token")] = None, ) -> StaffUser: if token is None: - raise AppException.unauthorized("token doestn exist") + raise AppException.unauthorized("Authentication token required") - # 1. Validate token with MCDI (live verification) + # 1. Validate token with MCDI (cached in Redis for sub-ms latency and rate-limit protection) try: - mcdi_data = await container.mcdi_service.validate_token(token) + mcdi_data = await container.mcdi_service.validate_token_cached( + redis=container.redis, + token=token, + ttl_seconds=60, + ) except Exception: raise AppException.unauthorized("MCDI session invalid or expired") diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index b4a30d6..0845567 100644 --- a/app/deps/token_auth.py +++ b/app/deps/token_auth.py @@ -5,7 +5,7 @@ from fastapi import Depends, HTTPException from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer -from app.container import get_container, Container +from app.container import Container, get_container from app.core.config import settings from app.core.securite import decode_access_mobile_token from app.infra.redis import RedisClient @@ -15,21 +15,74 @@ security = HTTPBearer() +def _check_session_validity( + idle_expires_at: datetime, + absolute_expires_at: datetime, + blocked: bool, +) -> None: + now = datetime.now(timezone.utc) + if idle_expires_at < now or absolute_expires_at < now: + raise HTTPException(status_code=401, detail="Session expired") + if blocked: + raise HTTPException(status_code=403, detail="User is blocked") + + +async def _validate_mcdi_session( + container: Container, + redis: RedisClient, + mcdi_token: str | None, +) -> None: + if not mcdi_token: + return + try: + await container.mcdi_service.validate_token_cached( + redis=redis, + token=mcdi_token, + ttl_seconds=300, + ) + except Exception: + raise HTTPException(status_code=401, detail="MCDI session invalid or expired") + + +async def _touch_session_activity( + container: Container, + redis: RedisClient, + cached: MobileSessionCache, + now: datetime, +) -> None: + if (now - cached.last_active).total_seconds() <= settings.SESSION_ACTIVITY_THROTTLE_SECONDS: + return + + new_idle_expires_at = min( + now + timedelta(days=settings.MOBILE_SESSION_DAYS), + cached.absolute_expires_at, + ) + await container.session_service.session_querier.update_session_activity( + id=cached.session_id, + idle_expires_at=new_idle_expires_at, + ) + await SessionService.cache_session_for_auth( + redis=redis, + session_id=cached.session_id, + user_id=cached.user_id, + email=cached.email, + idle_expires_at=new_idle_expires_at, + absolute_expires_at=cached.absolute_expires_at, + blocked=cached.blocked, + ttl=settings.MOBILE_SESSION_TTL_SECONDS, + last_active=now, + mcdi_token=cached.mcdi_token, + ) + + async def get_current_mobile_user( credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)], container: Annotated[Container, Depends(get_container)], ) -> MobileUserSchema: """ Dependency to get the current logged-in mobile user. - Fast path: Redis cache hit. Usually 0 DB queries; occasionally 1 cheap, - throttled UPDATE to last_active (see SESSION_ACTIVITY_THROTTLE_SECONDS) — - this is not a full round trip through the slow path, just a single - indexed write on the request's existing connection. - Slow path: Postgres fallback (2 DB queries) with cache re-population. - - idle_expires_at slides forward on each throttled activity refresh, up to - MOBILE_SESSION_DAYS from now, but is capped so it never exceeds - absolute_expires_at — the hard ceiling set at login that never moves. + Fast path: Redis cache hit. Usually 0 DB queries; throttled touch to last_active. + Slow path: Postgres fallback with cache re-population. """ token = credentials.credentials payload = decode_access_mobile_token(token) @@ -39,51 +92,20 @@ async def get_current_mobile_user( raise HTTPException(status_code=401, detail="Invalid token") session_id = uuid.UUID(session_id_str) + redis = RedisClient.get_instance() # --- Fast path: Redis cache --- - redis = RedisClient.get_instance() cached: MobileSessionCache | None = await SessionService.get_cached_session( redis, session_id ) if cached is not None: - now = datetime.now(timezone.utc) - if cached.idle_expires_at < now or cached.absolute_expires_at < now: - raise HTTPException(status_code=401, detail="Session expired") - if cached.blocked: - raise HTTPException(status_code=403, detail="User is blocked") - - - # MCDI validation in fast path (async task or inline? We should inline for security if required, but for mobile we can do it inline since redis is fast) - # But wait, MCDI validate_token requires a token. - if cached.mcdi_token: - try: - await container.mcdi_service.validate_token(cached.mcdi_token) - except Exception: - raise HTTPException(status_code=401, detail="MCDI session invalid") - - if ( - now - cached.last_active - ).total_seconds() > settings.SESSION_ACTIVITY_THROTTLE_SECONDS: - new_idle_expires_at = min( - now + timedelta(days=settings.MOBILE_SESSION_DAYS), - cached.absolute_expires_at, - ) - await container.session_service.session_querier.update_session_activity( - id=cached.session_id, - idle_expires_at=new_idle_expires_at, - ) - await SessionService.cache_session_for_auth( - redis=redis, - session_id=cached.session_id, - user_id=cached.user_id, - email=cached.email, - idle_expires_at=new_idle_expires_at, - absolute_expires_at=cached.absolute_expires_at, - blocked=cached.blocked, - ttl=settings.MOBILE_SESSION_TTL_SECONDS, - last_active=now, - mcdi_token=cached.mcdi_token, - ) + _check_session_validity( + cached.idle_expires_at, + cached.absolute_expires_at, + cached.blocked, + ) + await _validate_mcdi_session(container, redis, cached.mcdi_token) + await _touch_session_activity(container, redis, cached, datetime.now(timezone.utc)) return MobileUserSchema( user_id=cached.user_id, @@ -98,19 +120,16 @@ async def get_current_mobile_user( if not session: raise HTTPException(status_code=401, detail="Session not found") - now = datetime.now(timezone.utc) - if session.idle_expires_at < now or session.absolute_expires_at < now: - raise HTTPException(status_code=401, detail="Session expired") - user = await container.auth_service.user_querier.get_user_by_id(id=session.user_id) if not user: raise HTTPException(status_code=401, detail="User not found") - if user.blocked: - raise HTTPException(status_code=403, detail="User is blocked") - # Re-populate cache so next request hits Redis. The session row was just - # fetched fresh from Postgres, so its last_active is already accurate — - # no extra write needed here, only cache population. + _check_session_validity( + session.idle_expires_at, + session.absolute_expires_at, + user.blocked, + ) + await SessionService.cache_session_for_auth( redis=redis, session_id=session.id, @@ -121,7 +140,7 @@ async def get_current_mobile_user( blocked=user.blocked, ttl=settings.MOBILE_SESSION_TTL_SECONDS, last_active=session.last_active, - mcdi_token=None, # MCDI token not available on slow path, will be re-fetched on login + mcdi_token=None, ) return MobileUserSchema( @@ -135,12 +154,7 @@ async def require_onboarded_mobile_user( current_user: Annotated[MobileUserSchema, Depends(get_current_mobile_user)], container: Annotated[Container, Depends(get_container)], ) -> MobileUserSchema: - """Gate for endpoints that require a completed face enrollment. - Auth (login/me/devices/etc.), /enroll, and /event/join stay reachable - via plain get_current_mobile_user so a new user can always finish - onboarding; everything else (photos, notifications, audits, /event/me) - depends on this instead. - """ + """Gate for endpoints that require a completed face enrollment.""" user = await container.auth_service.user_querier.get_user_by_id( id=current_user.user_id ) diff --git a/app/router/web/auth.py b/app/router/web/auth.py index c9ef6ca..00f0c12 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,43 +1,42 @@ -from fastapi import APIRouter, Depends, Request, Cookie -from app.container import Container, get_container -from fastapi import Response +import secrets + +from fastapi import APIRouter, Cookie, Depends, Request, Response from fastapi.responses import RedirectResponse +from app.container import Container, get_container from app.core.config import settings +from app.core.exceptions import AppException from app.deps.cookie_auth import get_current_staff_user from app.schema.response.web.staff_user import StaffUserSchema from db.generated.models import StaffUser -from app.core.exceptions import AppException -import urllib.parse -import secrets router = APIRouter(prefix="/auth") @router.get("/mcdi/login") -async def mcdi_login(r: Response) -> RedirectResponse: +async def mcdi_login( + container: Container = Depends(get_container), +) -> RedirectResponse: state = secrets.token_urlsafe(16) - r.set_cookie( - "mcdi_state", - state, + url = container.mcdi_service.get_sso_authorize_url( + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, + state=state, + ) + response = RedirectResponse(url=url) + response.set_cookie( + key="mcdi_state", + value=state, httponly=True, max_age=300, secure=settings.environment != "dev", - samesite="lax" + samesite="lax", ) - params = urllib.parse.urlencode({ - "client_id": settings.MCDI_PROJECT_ID, - "redirect_uri": settings.MCDI_REDIRECT_URI_WEB, - "state": state - }) - url = f"{settings.MCDI_BASE_URL.replace('/api', '')}/api/auth/sso/authorize?{params}" - return RedirectResponse(url) + return response @router.get("/mcdi/callback") async def mcdi_callback( request: Request, - r: Response, code: str, state: str, container: Container = Depends(get_container), @@ -46,7 +45,10 @@ async def mcdi_callback( if not cookie_state or state != cookie_state: raise AppException.unauthorized("Invalid state parameter") - mcdi_data = await container.mcdi_service.exchange_code(code, settings.MCDI_REDIRECT_URI_WEB) + mcdi_data = await container.mcdi_service.exchange_code( + code=code, + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, + ) # Extract email and verify staff access email = mcdi_data.get("member", {}).get("email") @@ -61,7 +63,6 @@ async def mcdi_callback( if not token: raise AppException.internal_error("MCDI did not return a session token") - # Let the frontend dashboard URL be determined by CORS origin or environment frontend_url = settings.CORS_ORIGINS[0] if settings.CORS_ORIGINS else "http://localhost:5173" redirect_res = RedirectResponse(url=f"{frontend_url}/dashboard") @@ -96,8 +97,9 @@ async def admin_logout( token: str | None = Cookie(default=None, alias="access_token"), container: Container = Depends(get_container), ) -> None: - # We could also call MCDI logout endpoint here to kill the session globally or for this project - # But removing the cookie is the minimum required. + if token: + await container.mcdi_service.logout(token, redis=container.redis) + r.delete_cookie( key="access_token", httponly=True, diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 563bbe6..5685770 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -69,11 +69,43 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: if info.field_name == "device_type": return stripped.lower() return stripped +class MobileRegisterRequest(MobileAuthBaseRequest): + @field_validator("password") + @classmethod + def _validate_password_complexity(cls, value: str) -> str: + if not any(c.isupper() for c in value): + raise ValueError("Password must contain at least one uppercase letter") + if not any(c.islower() for c in value): + raise ValueError("Password must contain at least one lowercase letter") + if not any(c.isdigit() for c in value): + raise ValueError("Password must contain at least one digit") + if not any(not c.isalnum() for c in value): + raise ValueError("Password must contain at least one special character") + return value + + +class MobileLoginRequest(MobileAuthBaseRequest): + pass +class RegisterVerifyRequest(MobileAuthBaseRequest): + otp: str = Field( + ..., + min_length=6, + max_length=6, + description="The 6-digit OTP code sent via email", + ) +class ResendOtpRequest(BaseModel): + email: EmailStr = Field(..., max_length=255) + @field_validator("email", mode="before") + @classmethod + def _normalize_email(cls, value: object) -> object: + if not isinstance(value, str): + return value + return value.strip().lower() class RefreshTokenRequest(BaseModel): refresh_token: str diff --git a/app/service/mcdi.py b/app/service/mcdi.py index 43ad4c9..e15cc6a 100644 --- a/app/service/mcdi.py +++ b/app/service/mcdi.py @@ -1,7 +1,16 @@ -import httpx +import hashlib +import json +import logging +import urllib.parse from typing import Any + +import httpx + from app.core.config import settings from app.core.exceptions import AppException +from app.infra.redis import RedisClient + +logger = logging.getLogger(__name__) class McdiService: @@ -9,9 +18,39 @@ def __init__(self) -> None: self.base_url = settings.MCDI_BASE_URL.rstrip("/") self.api_key = settings.MCDI_API_KEY self.client_id = settings.MCDI_PROJECT_ID + self.server_id = settings.MCDI_SERVER_ID + self._timeout = httpx.Timeout(10.0, connect=5.0) + + def get_sso_authorize_url(self, redirect_uri: str, state: str) -> str: + """Constructs the MCDI SSO authorization URL matching MCDI's AuthorizeQueryDto.""" + params = urllib.parse.urlencode({ + "client_id": self.client_id, + "redirect_uri": redirect_uri, + "server_id": self.server_id, + "state": state, + }) + # MCDI_BASE_URL ends with /api (e.g. https://mcdi.microclub.info/api) + # The authorize route is /api/auth/sso/authorize + return f"{self.base_url}/auth/sso/authorize?{params}" + + @staticmethod + def _parse_error_detail(response: httpx.Response, default: str) -> str: + try: + body = response.json() + msg = body.get("message") + if isinstance(msg, list): + return ", ".join(str(m) for m in msg) + if isinstance(msg, str) and msg: + return msg + err = body.get("error") + if isinstance(err, str) and err: + return err + except Exception: + pass + return default async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: - """Exchanges an OAuth code for a session token and member info.""" + """Exchanges an OAuth callback code for a long-lived MCDI session token.""" url = f"{self.base_url}/auth/token" headers = { "X-API-Key": self.api_key, @@ -23,33 +62,88 @@ async def exchange_code(self, code: str, redirect_uri: str) -> dict[str, Any]: "redirectUri": redirect_uri, } - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: try: response = await client.post(url, json=payload, headers=headers) if response.status_code != 200: - # MCDI standardizes errors - error_detail = response.json().get("message", "Failed to exchange MCDI code") - raise AppException.unauthorized(f"MCDI Error: {error_detail}") + detail = self._parse_error_detail(response, "Failed to exchange MCDI code") + logger.warning("mcdi_exchange_failed status=%d detail=%s", response.status_code, detail) + raise AppException.unauthorized(f"MCDI Error: {detail}") return response.json() except httpx.RequestError as e: + logger.error("mcdi_network_error url=%s err=%s", url, e) raise AppException.internal_error(f"MCDI service unavailable: {e}") async def validate_token(self, token: str) -> dict[str, Any]: - """Validates an MCDI session token and fetches live roles.""" + """Validates an MCDI session token and fetches live roles directly from MCDI.""" url = f"{self.base_url}/auth/validate" headers = { "X-API-Key": self.api_key, "Content-Type": "application/json", } - payload = { - "token": token - } + payload = {"token": token} - async with httpx.AsyncClient() as client: + async with httpx.AsyncClient(timeout=self._timeout) as client: try: response = await client.post(url, json=payload, headers=headers) if response.status_code != 200: - raise AppException.unauthorized("Invalid or expired MCDI session") + detail = self._parse_error_detail(response, "Invalid or expired MCDI session") + logger.warning("mcdi_validation_failed status=%d detail=%s", response.status_code, detail) + raise AppException.unauthorized(f"MCDI validation failed: {detail}") return response.json() except httpx.RequestError as e: + logger.error("mcdi_network_error url=%s err=%s", url, e) raise AppException.internal_error(f"MCDI service unavailable: {e}") + + async def validate_token_cached( + self, + redis: RedisClient, + token: str, + ttl_seconds: int = 60, + ) -> dict[str, Any]: + """ + Validates token against Redis cache to prevent latency and avoid hitting MCDI rate limits + (MCDI limits /auth/validate to 60 req/min). Falls back to remote validation on cache miss. + """ + token_hash = hashlib.sha256(token.encode()).hexdigest() + cache_key = f"mcdi:session:{token_hash}" + + cached = await redis.get(cache_key) + if cached: + try: + return json.loads(cached) + except Exception: + pass + + # Cache miss: validate against MCDI + data = await self.validate_token(token) + try: + await redis.set(cache_key, json.dumps(data), expire=ttl_seconds) + except Exception as e: + logger.warning("mcdi_cache_write_failed err=%s", e) + + return data + + async def logout(self, token: str, redis: RedisClient | None = None) -> bool: + """Kills the project-scoped session on MCDI and purges local Redis validation cache.""" + token_hash = hashlib.sha256(token.encode()).hexdigest() + if redis: + try: + await redis.delete(f"mcdi:session:{token_hash}") + except Exception as e: + logger.warning("mcdi_cache_delete_failed err=%s", e) + + url = f"{self.base_url}/auth/logout" + headers = { + "X-API-Key": self.api_key, + "Content-Type": "application/json", + } + payload = {"token": token} + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + return response.status_code == 200 + except Exception as e: + logger.warning("mcdi_logout_failed err=%s", e) + return False diff --git a/app/service/staff_user.py b/app/service/staff_user.py index ace9644..fcf5ac6 100644 --- a/app/service/staff_user.py +++ b/app/service/staff_user.py @@ -5,8 +5,7 @@ import uuid from app.core.exceptions import AppException, DBException, DBExceptionImpl -from app.core.securite import create_access_staff_token, hash_password, verify_password -from app.schema.response.web.auth import WebAuthResponse +from app.core.securite import hash_password from db.generated import staff_user as staff_queries from db.generated.staff_user import ListStaffUsersParams from db.generated.models import StaffUser, StaffRole diff --git a/app/service/users.py b/app/service/users.py index e076d28..f57e688 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,6 +1,5 @@ from datetime import datetime, timedelta, timezone import uuid -import sqlalchemy from collections.abc import AsyncIterable from typing import Optional, Any @@ -50,14 +49,14 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, - mcdi_service: McdiService, + mcdi_service: McdiService | None = None, ): self.user_querier = user_querier self.device_querier = device_querier self.session_querier = session_querier self.refresh_token_querier = refresh_token_querier self.face_embedding_service = face_embedding_service - self.mcdi_service = mcdi_service + self.mcdi_service = mcdi_service or McdiService() async def _ensure_device_for_login( self, @@ -109,8 +108,9 @@ async def mcdi_exchange( mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) - email = mcdi_data.get("member", {}).get("email") - discord_id = mcdi_data.get("member", {}).get("discordId") + member = mcdi_data.get("member", {}) + email = member.get("email") + discord_id = member.get("id") or member.get("discordId") mcdi_token = mcdi_data.get("token") if not email or not discord_id or not mcdi_token: @@ -124,11 +124,12 @@ async def mcdi_exchange( if not user: user = await self.user_querier.get_user_by_email(email=email) if user: - # Update with discord_id using direct DB execution since generated query doesn't exist - await self.user_querier._conn.execute( - sqlalchemy.text("UPDATE users SET discord_id = :d WHERE id = :id"), - {"d": discord_id, "id": user.id} + user = await self.user_querier.update_user_discord_id( + discord_id=discord_id, + id=user.id, ) + if not user: + raise AppException.internal_error("Failed to link Discord account") else: user = await self.user_querier.create_user( email=email, hashed_password=None, discord_id=discord_id @@ -346,6 +347,10 @@ async def logout( session_id: str, ) -> dict[str, str]: sid = uuid.UUID(session_id) + cached = await SessionService.get_cached_session(redis, sid) + if cached and cached.mcdi_token: + await self.mcdi_service.logout(cached.mcdi_token, redis=redis) + await self.session_querier.delete_session_by_id( id=sid, user_id=uuid.UUID(user_id) ) diff --git a/db/generated/user.py b/db/generated/user.py index 6fc8d7e..ea69682 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -139,6 +139,15 @@ class ListUsersWithEmbeddingRow: """ +UPDATE_USER_DISCORD_ID = """-- name: update_user_discord_id \\:one +UPDATE users +SET discord_id = :p1, + updated_at = NOW() +WHERE id = :p2 +RETURNING id, email, hashed_password, created_at, updated_at, display_name, face_embedding, deleted_at, blocked, avatar_key, discord_id +""" + + class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn @@ -364,3 +373,21 @@ async def update_user_password(self, *, hashed_password: Optional[str], id: uuid avatar_key=row[9], discord_id=row[10], ) + + async def update_user_discord_id(self, *, discord_id: str, id: uuid.UUID) -> Optional[models.User]: + row = (await self._conn.execute(sqlalchemy.text(UPDATE_USER_DISCORD_ID), {"p1": discord_id, "p2": id})).first() + if row is None: + return None + return models.User( + id=row[0], + email=row[1], + hashed_password=row[2], + created_at=row[3], + updated_at=row[4], + display_name=row[5], + face_embedding=row[6], + deleted_at=row[7], + blocked=row[8], + avatar_key=row[9], + discord_id=row[10], + ) diff --git a/db/queries/user.sql b/db/queries/user.sql index e1d0b88..c6c3a17 100644 --- a/db/queries/user.sql +++ b/db/queries/user.sql @@ -24,6 +24,13 @@ SELECT * FROM users WHERE discord_id = $1; +-- name: UpdateUserDiscordId :one +UPDATE users +SET discord_id = $1, + updated_at = NOW() +WHERE id = $2 +RETURNING *; + -- name: UpdateUserPassword :one UPDATE users SET hashed_password = $1, diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py new file mode 100644 index 0000000..339bfe6 --- /dev/null +++ b/tests/unit/test_mcdi_auth.py @@ -0,0 +1,201 @@ +import json +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fastapi import HTTPException + +from app.core.config import settings +from app.schema.request.mobile.auth import McdiExchangeRequest +from app.service.mcdi import McdiService +from app.service.users import AuthService + + +@pytest.fixture +def mcdi_service() -> McdiService: + return McdiService() + + +def test_get_sso_authorize_url(mcdi_service: McdiService) -> None: + redirect_uri = "https://app.example.com/callback" + state = "random-state-123" + url = mcdi_service.get_sso_authorize_url(redirect_uri, state) + + assert "/auth/sso/authorize?" in url + assert f"client_id={settings.MCDI_PROJECT_ID}" in url + assert f"server_id={settings.MCDI_SERVER_ID}" in url + assert "state=random-state-123" in url + assert "redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback" in url + + +@pytest.mark.asyncio +async def test_exchange_code_success(mcdi_service: McdiService) -> None: + expected_response = { + "token": "sess_123456", + "member": { + "id": "discord_999", + "username": "alice", + "email": "alice@microclub.info", + "isClubMember": True, + }, + "roles": [{"roleId": "r1", "roleName": "Bureau"}], + } + + mock_resp = MagicMock() + mock_resp.status_code = 200 + mock_resp.json.return_value = expected_response + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + res = await mcdi_service.exchange_code("code_abc", "https://redirect.uri") + + assert res["token"] == "sess_123456" + assert res["member"]["id"] == "discord_999" + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + assert kwargs["json"]["code"] == "code_abc" + assert kwargs["json"]["clientId"] == settings.MCDI_PROJECT_ID + + +@pytest.mark.asyncio +async def test_exchange_code_failure(mcdi_service: McdiService) -> None: + mock_resp = MagicMock() + mock_resp.status_code = 400 + mock_resp.json.return_value = {"message": "Invalid callback code"} + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + with pytest.raises(HTTPException) as exc_info: + await mcdi_service.exchange_code("bad_code", "https://redirect.uri") + assert "Invalid callback code" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_validate_token_cached_hit(mcdi_service: McdiService) -> None: + redis = AsyncMock() + cached_payload = { + "member": {"id": "123", "email": "alice@club.net"}, + "roles": [], + } + redis.get.return_value = json.dumps(cached_payload) + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + data = await mcdi_service.validate_token_cached(redis, "token_xyz", ttl_seconds=60) + assert data == cached_payload + mock_post.assert_not_called() + + +@pytest.mark.asyncio +async def test_validate_token_cached_miss_populates_cache(mcdi_service: McdiService) -> None: + redis = AsyncMock() + redis.get.return_value = None + + mock_resp = MagicMock() + mock_resp.status_code = 200 + remote_payload = { + "member": {"id": "123", "email": "alice@club.net"}, + "roles": [], + } + mock_resp.json.return_value = remote_payload + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + data = await mcdi_service.validate_token_cached(redis, "token_xyz", ttl_seconds=60) + + assert data == remote_payload + mock_post.assert_called_once() + redis.set.assert_called_once() + call_args = redis.set.call_args + assert call_args[1]["expire"] == 60 + + +@pytest.mark.asyncio +async def test_logout_invalidates_cache_and_calls_mcdi(mcdi_service: McdiService) -> None: + redis = AsyncMock() + mock_resp = MagicMock() + mock_resp.status_code = 200 + + with patch("httpx.AsyncClient.post", new_callable=AsyncMock) as mock_post: + mock_post.return_value = mock_resp + ok = await mcdi_service.logout("token_to_kill", redis=redis) + + assert ok is True + redis.delete.assert_called_once() + mock_post.assert_called_once() + assert "/auth/logout" in mock_post.call_args[0][0] + + +@pytest.mark.asyncio +async def test_users_service_mcdi_exchange_creates_new_user() -> None: + user_querier = AsyncMock() + device_querier = AsyncMock() + session_querier = AsyncMock() + refresh_token_querier = AsyncMock() + face_service = MagicMock() + mock_mcdi = AsyncMock() + redis = AsyncMock() + redis.incr.return_value = 1 + + auth_service = AuthService( + user_querier=user_querier, + device_querier=device_querier, + session_querier=session_querier, + refresh_token_querier=refresh_token_querier, + face_embedding_service=face_service, + mcdi_service=mock_mcdi, + ) + + mock_mcdi.exchange_code.return_value = { + "token": "mcdi_sess_tok", + "member": { + "id": "discord_12345", + "email": "bob@microclub.info", + "username": "bob", + }, + } + + user_querier.get_user_by_discord_id.return_value = None + user_querier.get_user_by_email.return_value = None + + new_user_mock = MagicMock() + new_user_mock.id = uuid.uuid4() + new_user_mock.email = "bob@microclub.info" + new_user_mock.blocked = False + user_querier.create_user.return_value = new_user_mock + user_querier.get_user_by_id_for_update.return_value = new_user_mock + + device_mock = MagicMock() + device_mock.id = uuid.uuid4() + device_querier.get_device_by_physical_id.return_value = None + device_querier.create_device.return_value = device_mock + + session_mock = MagicMock() + session_mock.id = uuid.uuid4() + session_mock.user_id = new_user_mock.id + session_mock.idle_expires_at = MagicMock() + session_mock.absolute_expires_at = MagicMock() + session_mock.last_active = MagicMock() + session_querier.upsert_session.return_value = session_mock + + async def _empty_async_iter(*args, **kwargs): + if False: + yield + + session_querier.evict_overflow_sessions = MagicMock(side_effect=_empty_async_iter) + + req = McdiExchangeRequest( + code="code_from_discord", + device_name="iPhone 15", + device_type="ios", + physical_device_id=uuid.uuid4(), + ) + + res = await auth_service.mcdi_exchange(redis, req) + + assert res.is_new_user is True + assert res.user_id == new_user_mock.id + user_querier.create_user.assert_called_once_with( + email="bob@microclub.info", + hashed_password=None, + discord_id="discord_12345", + ) From 5d459acabeefe0e048294ae5190352160f69c051 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:45:55 +0100 Subject: [PATCH 07/11] test(mcdi): expand unit test suite to 11 test cases and cover staff auth and account linking --- db/generated/user.py | 2 +- tests/unit/test_mcdi_auth.py | 136 +++++++++++++++++++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) diff --git a/db/generated/user.py b/db/generated/user.py index ea69682..7fb2167 100644 --- a/db/generated/user.py +++ b/db/generated/user.py @@ -152,7 +152,7 @@ class AsyncQuerier: def __init__(self, conn: sqlalchemy.ext.asyncio.AsyncConnection): self._conn = conn - async def create_user(self, *, email: str, hashed_password: Optional[str], discord_id: Optional[str]) -> Optional[models.User]: + async def create_user(self, *, email: str, hashed_password: Optional[str] = None, discord_id: Optional[str] = None) -> Optional[models.User]: row = (await self._conn.execute(sqlalchemy.text(CREATE_USER), {"p1": email, "p2": hashed_password, "p3": discord_id})).first() if row is None: return None diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index 339bfe6..cb0033a 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -199,3 +199,139 @@ async def _empty_async_iter(*args, **kwargs): hashed_password=None, discord_id="discord_12345", ) + + +@pytest.mark.asyncio +async def test_users_service_mcdi_exchange_links_existing_user() -> None: + user_querier = AsyncMock() + device_querier = AsyncMock() + session_querier = AsyncMock() + refresh_token_querier = AsyncMock() + face_service = MagicMock() + mock_mcdi = AsyncMock() + redis = AsyncMock() + redis.incr.return_value = 1 + + auth_service = AuthService( + user_querier=user_querier, + device_querier=device_querier, + session_querier=session_querier, + refresh_token_querier=refresh_token_querier, + face_embedding_service=face_service, + mcdi_service=mock_mcdi, + ) + + mock_mcdi.exchange_code.return_value = { + "token": "mcdi_sess_tok_2", + "member": { + "id": "discord_existing_123", + "email": "existing@microclub.info", + "username": "existing_user", + }, + } + + existing_user = MagicMock() + existing_user.id = uuid.uuid4() + existing_user.email = "existing@microclub.info" + existing_user.blocked = False + + user_querier.get_user_by_discord_id.return_value = None + user_querier.get_user_by_email.return_value = existing_user + user_querier.update_user_discord_id.return_value = existing_user + user_querier.get_user_by_id_for_update.return_value = existing_user + + device_mock = MagicMock() + device_mock.id = uuid.uuid4() + device_querier.get_device_by_physical_id.return_value = None + device_querier.create_device.return_value = device_mock + + session_mock = MagicMock() + session_mock.id = uuid.uuid4() + session_mock.user_id = existing_user.id + session_mock.idle_expires_at = MagicMock() + session_mock.absolute_expires_at = MagicMock() + session_mock.last_active = MagicMock() + session_querier.upsert_session.return_value = session_mock + + async def _empty_async_iter(*args, **kwargs): + if False: + yield + + session_querier.evict_overflow_sessions = MagicMock(side_effect=_empty_async_iter) + + req = McdiExchangeRequest( + code="code_existing", + device_name="Pixel 8", + device_type="android", + physical_device_id=uuid.uuid4(), + ) + + res = await auth_service.mcdi_exchange(redis, req) + + assert res.is_new_user is False + assert res.user_id == existing_user.id + user_querier.update_user_discord_id.assert_called_once_with( + discord_id="discord_existing_123", + id=existing_user.id, + ) + user_querier.create_user.assert_not_called() + + +@pytest.mark.asyncio +async def test_cookie_auth_get_current_staff_user_success() -> None: + from app.deps.cookie_auth import get_current_staff_user + + container = MagicMock() + container.redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.staff_user_querier = AsyncMock() + + container.mcdi_service.validate_token_cached.return_value = { + "member": {"email": "admin@microclub.info", "id": "111"}, + "roles": [{"roleName": "Admin"}], + } + + mock_staff = MagicMock() + mock_staff.email = "admin@microclub.info" + container.staff_user_querier.get_staff_user_by_email.return_value = mock_staff + + staff = await get_current_staff_user(container=container, token="valid_staff_tok") + assert staff == mock_staff + + +@pytest.mark.asyncio +async def test_cookie_auth_get_current_staff_user_unauthorized_if_not_staff() -> None: + from app.deps.cookie_auth import get_current_staff_user + + container = MagicMock() + container.redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.staff_user_querier = AsyncMock() + + container.mcdi_service.validate_token_cached.return_value = { + "member": {"email": "stranger@discord.com", "id": "999"}, + "roles": [], + } + + container.staff_user_querier.get_staff_user_by_email.return_value = None + + with pytest.raises(HTTPException) as exc_info: + await get_current_staff_user(container=container, token="valid_mcdi_tok") + assert exc_info.value.status_code == 401 + assert "not authorized" in exc_info.value.detail.lower() + + +@pytest.mark.asyncio +async def test_token_auth_validate_mcdi_session_revoked() -> None: + from app.deps.token_auth import _validate_mcdi_session + + container = MagicMock() + redis = AsyncMock() + container.mcdi_service = AsyncMock() + container.mcdi_service.validate_token_cached.side_effect = Exception("revoked") + + with pytest.raises(HTTPException) as exc_info: + await _validate_mcdi_session(container, redis, "revoked_tok") + assert exc_info.value.status_code == 401 + assert "MCDI session invalid" in exc_info.value.detail + From e6588dd08e77bd36fad9d62e4b756923ad80bdf0 Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 00:58:12 +0100 Subject: [PATCH 08/11] feat(auth): restore legacy mobile auth endpoints for backward compatibility This commit restores the legacy mobile email/password and OTP login/registration endpoints and service methods, ensuring backward compatibility alongside the new MCDI SSO flow. Also includes minor test file formatting. --- app/router/mobile/auth.py | 93 +++++++++++++- app/service/users.py | 229 ++++++++++++++++++++++++++++++++++- tests/unit/test_mcdi_auth.py | 2 +- 3 files changed, 318 insertions(+), 6 deletions(-) diff --git a/app/router/mobile/auth.py b/app/router/mobile/auth.py index c3b0572..5e64b30 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -14,10 +14,10 @@ from app.schema.request.mobile.auth import ( McdiExchangeRequest, - - - - + MobileLoginRequest, + MobileRegisterRequest, + RegisterVerifyRequest, + ResendOtpRequest, RefreshTokenRequest, UpdateDeviceTokenRequest, InactivateDeviceRequest, @@ -29,11 +29,96 @@ MobileAuthResponse, SessionSchema, UserSchema, + RegisterPendingResponse, ) router = APIRouter(prefix="/auth") +@router.post( + "/register", + response_model=RegisterPendingResponse | MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_register( + req: MobileRegisterRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse | MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_register( + container.redis, req, client_ip=client_ip + ) + if isinstance(result, MobileAuthResponse): + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + metadata={"endpoint": "register", "otp_activated": False}, + ) + return result + + +@router.post( + "/register/resend-otp", + response_model=RegisterPendingResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_register_resend_otp( + req: ResendOtpRequest, + request: Request, + container: Container = Depends(get_container), +) -> RegisterPendingResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_register_resend_otp( + container.redis, req.email, client_ip=client_ip + ) + return result + + +@router.post( + "/register/verify", + response_model=MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=10, window=60))], +) +async def mobile_register_verify( + req: RegisterVerifyRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.verify_mobile_register( + container.redis, req, client_ip=client_ip + ) + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + metadata={"endpoint": "register_verify"}, + ) + return result + + +@router.post( + "/login", + response_model=MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mobile_login( + req: MobileLoginRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mobile_login( + container.redis, req, client_ip=client_ip + ) + await container.audit_service.create_record( + event_type=AuditEventType.USER_LOGIN, + user_id=result.user_id, + metadata={"endpoint": "login"}, + ) + return result + + @router.post( "/mcdi/exchange", response_model=MobileAuthResponse, diff --git a/app/service/users.py b/app/service/users.py index f57e688..44d6fe4 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -5,9 +5,12 @@ from fastapi import HTTPException +import json +import secrets from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, + verify_password, create_acces_mobile_token, create_raw_refresh_token, hash_refresh_token, @@ -16,12 +19,18 @@ ) from app.core.config import settings from app.infra.redis import RedisClient +from app.infra.nats import NatsClient from app.infra.minio import Bucket, IMAGES_BUCKET_NAME from app.service.mcdi import McdiService from app.schema.request.mobile.auth import ( McdiExchangeRequest, + MobileLoginRequest, + MobileRegisterRequest, + RegisterVerifyRequest, + MobileAuthBaseRequest, ) -from app.schema.response.mobile.auth import MobileAuthResponse +from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse +from sqlalchemy.exc import SQLAlchemyError from db.generated import user as user_queries from db.generated import devices as device_queries from db.generated import session as session_queries @@ -93,6 +102,224 @@ async def _ensure_device_for_login( raise AppException.internal_error("Failed to create device") return device + async def mobile_login( + self, + redis: RedisClient, + req: MobileLoginRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + logger.info("mobile_login attempt") + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit( + redis, + f"rate:ip:{client_ip}", + max_attempts, + window, + ) + await self.check_rate_limit( + redis, + f"rate:email:{req.email}", + max_attempts, + window, + ) + + existing_user = await self.user_querier.get_user_by_email(email=req.email) + if existing_user is None: + logger.warning("login attempt: user_not_found") + raise AppException.unauthorized( + "User not found; consider registering instead" + ) + if existing_user.blocked: + logger.warning("login attempt: user_blocked user_id=%s", existing_user.id) + raise AppException.forbidden("User is blocked") + if not verify_password(req.password, existing_user.hashed_password or ""): + logger.warning( + "login attempt: invalid_credentials user_id=%s", existing_user.id + ) + raise AppException.unauthorized("Invalid credentials") + + locked_user = await self.user_querier.get_user_by_id_for_update( + id=existing_user.id + ) + if not locked_user: + raise AppException.unauthorized("User not found") + if locked_user.blocked: + logger.warning( + "login attempt: user_blocked_at_commit user_id=%s", locked_user.id + ) + raise AppException.forbidden("User is blocked") + + logger.info("login success user_id=%s", locked_user.id) + return await self._create_mobile_session( + redis=redis, + user=locked_user, + req=req, + is_new_user=False, + ) + + async def mobile_register( + self, + redis: RedisClient, + req: MobileRegisterRequest, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse | MobileAuthResponse: + logger.info("mobile_register attempt") + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit( + redis, + f"rate:ip:{client_ip}", + max_attempts, + window, + ) + await self.check_rate_limit( + redis, + f"rate:email:{req.email}", + max_attempts, + window, + ) + + existing_user = await self.user_querier.get_user_by_email(email=req.email) + if existing_user is not None: + logger.warning("register attempt: email_already_in_use") + raise AppException.conflict("Email already in use; please login instead") + + hashed = hash_password(req.password) + + if not settings.OTP_ACTIVATED: + logger.info("OTP deactivated, creating user directly") + user = await self.user_querier.create_user( + email=req.email, hashed_password=hashed + ) + if not user: + raise AppException.internal_error("Failed to create user") + return await self._create_mobile_session( + redis=redis, + user=user, + req=req, + is_new_user=True, + ) + + pending_key = f"pending_user:{req.email}" + pending_data = { + "hashed_password": hashed, + } + + # Save in Redis for 10 minutes (600 seconds) + await redis.set(pending_key, json.dumps(pending_data), expire=600) + + if settings.environment == "dev": + otp = settings.DEV_OTP_BYPASS_CODE + await redis.set(f"otp:{req.email}", otp, expire=600) + logger.info("dev OTP bypass active, otp=%s email=%s", otp, req.email) + else: + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + await redis.set(f"otp:{req.email}", otp, expire=600) + # Send to NATS + await NatsClient.js_publish( + "email.send_otp", + json.dumps({"email": req.email, "otp": otp}).encode("utf-8"), + ) + + logger.info("register success, OTP sent") + return RegisterPendingResponse( + message="OTP sent to email", status="pending_verification", email=req.email + ) + + async def mobile_register_resend_otp( + self, + redis: RedisClient, + email: str, + client_ip: Optional[str] = None, + ) -> RegisterPendingResponse: + logger.info("resend_otp attempt for %s", email) + max_attempts = settings.RATE_LIMIT_LOGIN_MAX_ATTEMPTS + window = settings.RATE_LIMIT_LOGIN_WINDOW_SECONDS + + if client_ip: + await self.check_rate_limit( + redis, + f"rate:ip:{client_ip}", + max_attempts, + window, + ) + await self.check_rate_limit( + redis, + f"rate:email:{email}", + max_attempts, + window, + ) + + pending_key = f"pending_user:{email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.not_found("No pending registration found for this email") + + if settings.environment == "dev": + otp = settings.DEV_OTP_BYPASS_CODE + await redis.set(f"otp:{email}", otp, expire=600) + logger.info("dev OTP bypass active, otp=%s email=%s", otp, email) + else: + otp = "".join(secrets.choice("0123456789") for _ in range(6)) + # Regenerate OTP with 10 mins TTL, without touching the pending_user TTL + await redis.set(f"otp:{email}", otp, expire=600) + # Send to NATS + await NatsClient.js_publish( + "email.send_otp", + json.dumps({"email": email, "otp": otp}).encode("utf-8"), + ) + + logger.info("resend_otp success, new OTP sent to %s", email) + return RegisterPendingResponse( + message="New OTP sent to email", status="pending_verification", email=email + ) + + async def verify_mobile_register( + self, + redis: RedisClient, + req: RegisterVerifyRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + otp_key = f"otp:{req.email}" + stored_otp = await redis.get(otp_key) + + if not stored_otp or stored_otp != req.otp: + raise AppException.unauthorized("Invalid or expired OTP") + + pending_key = f"pending_user:{req.email}" + raw_data = await redis.get(pending_key) + if not raw_data: + raise AppException.unauthorized("Registration session expired") + + data = json.loads(raw_data) + + try: + user = await self.user_querier.create_user( + email=req.email, hashed_password=data["hashed_password"] + ) + if not user: + raise AppException.internal_error("Failed to create user") + except SQLAlchemyError as exc: + logger.error("Failed to create user: %s", exc) + raise DBException.handle(exc) + + # Clean up redis + await redis.delete(otp_key) + await redis.delete(pending_key) + + logger.info("register verify success user_id=%s", user.id) + return await self._create_mobile_session( + redis=redis, + user=user, + req=req, + is_new_user=True, + ) + async def mcdi_exchange( self, redis: RedisClient, diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index cb0033a..2ced974 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -329,7 +329,7 @@ async def test_token_auth_validate_mcdi_session_revoked() -> None: redis = AsyncMock() container.mcdi_service = AsyncMock() container.mcdi_service.validate_token_cached.side_effect = Exception("revoked") - + with pytest.raises(HTTPException) as exc_info: await _validate_mcdi_session(container, redis, "revoked_tok") assert exc_info.value.status_code == 401 From a1b0e657533c60a9eb2032863893f205f6e1ca2e Mon Sep 17 00:00:00 2001 From: ademboukabes Date: Wed, 7 Oct 2026 01:15:16 +0100 Subject: [PATCH 09/11] fix(lint): remove unused import and trailing whitespace --- app/service/users.py | 1 - tests/unit/test_mcdi_auth.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/service/users.py b/app/service/users.py index 44d6fe4..20963f5 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -27,7 +27,6 @@ MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, - MobileAuthBaseRequest, ) from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse from sqlalchemy.exc import SQLAlchemyError diff --git a/tests/unit/test_mcdi_auth.py b/tests/unit/test_mcdi_auth.py index 2ced974..cb0033a 100644 --- a/tests/unit/test_mcdi_auth.py +++ b/tests/unit/test_mcdi_auth.py @@ -329,7 +329,7 @@ async def test_token_auth_validate_mcdi_session_revoked() -> None: redis = AsyncMock() container.mcdi_service = AsyncMock() container.mcdi_service.validate_token_cached.side_effect = Exception("revoked") - + with pytest.raises(HTTPException) as exc_info: await _validate_mcdi_session(container, redis, "revoked_tok") assert exc_info.value.status_code == 401 From 8f97932733e208184a6eb5a10f2191eae7190e1d Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 7 Oct 2026 00:32:36 +0000 Subject: [PATCH 10/11] feat: add .gitignore, Oxlint config, README, and package-lock for frontend setup Co-authored-by: adem boukabes <142881379+ademboukabes@users.noreply.github.com> --- frontend/.gitignore | 24 + frontend/.oxlintrc.json | 8 + frontend/README.md | 32 + frontend/index.html | 13 + frontend/package-lock.json | 1467 +++++++++++++++++++++++++++++++++ frontend/package.json | 29 + frontend/public/favicon.svg | 1 + frontend/public/icons.svg | 24 + frontend/src/App.css | 184 +++++ frontend/src/App.tsx | 122 +++ frontend/src/assets/hero.png | Bin 0 -> 13057 bytes frontend/src/assets/react.svg | 1 + frontend/src/assets/vite.svg | 1 + frontend/src/index.css | 111 +++ frontend/src/main.tsx | 10 + frontend/tsconfig.app.json | 26 + frontend/tsconfig.json | 7 + frontend/tsconfig.node.json | 23 + frontend/vite.config.ts | 7 + 19 files changed, 2090 insertions(+) create mode 100644 frontend/.gitignore create mode 100644 frontend/.oxlintrc.json create mode 100644 frontend/README.md create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.svg create mode 100644 frontend/public/icons.svg create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/assets/hero.png create mode 100644 frontend/src/assets/react.svg create mode 100644 frontend/src/assets/vite.svg create mode 100644 frontend/src/index.css create mode 100644 frontend/src/main.tsx create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/frontend/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..00fb4fc --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1467 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "lucide-react": "^1.52.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "sonner": "^2.0.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.87.0.tgz", + "integrity": "sha512-22tVIHlB9898OYsBKmYB6He1MCSqdTmK1BVWuSNUTVrpS0KWNege+U9uEYqGJ+KatQag0r/oU44i3OOs46z7HQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.87.0.tgz", + "integrity": "sha512-JfZQ3MWf74n8ov0DojZpkOduIubYJvZL5tbTMMhDutsal6u1xH1QuNpN+lK+EQwF8AeAfgwTR2fgh5U2br27XA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.87.0.tgz", + "integrity": "sha512-rkx6Xvvw93z2GImVOyvbZwAZcoY0Z6MJW177XNsSB+i/CBsH7ANyelTp01VZyIkM3iSXtqKVb4+ptBgPHap5pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.87.0.tgz", + "integrity": "sha512-cw5nYw7L/pQ2SgGBgOcg/Qo89llQre3vIc/kuBLhSICzu1r5s8L76H3ug6AIFsD98WpROj8/z5JVzkf4daxHaQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.87.0.tgz", + "integrity": "sha512-x/SuznE6loRZK9gz9AS4ZmzNzSu+5KdeoCS+sVHhnrE8HM6AD8UyTjwldQTTqlXGvkwHASgB0NiTNOc3o3PdAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.87.0.tgz", + "integrity": "sha512-+JEDmCAXiiFmKhAjZhgAOwaHWY6WENOQ5QA+lFuY59tgsUCDOTRi46B+lENK6CWvtxsXpwkZXNOgpjAQcpIpnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.87.0.tgz", + "integrity": "sha512-/uC9XqXLAC9AHFLNycwmSQEvKqGrOCgU+uXALI7gNW0DG81TJ/3T2eHnKdRvcb5R1gFEfjxDwI4e4zIa29rICQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.87.0.tgz", + "integrity": "sha512-FTRYPsBy/M728vnUv7l+KAwSAa3Qi4BQbhVvy7NYuMTZ0sUa/OWsbMx79HVFw6u/5KWcaYayDsoOiv7xbevzDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.87.0.tgz", + "integrity": "sha512-6sC+N7IY0h1egd8SrYUGNN7CUyKJZ4bIZakKa7V7hfinf4jfi8Kq4+klJvFp5DqXcwMn44I5MeBwmJluO1odFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.87.0.tgz", + "integrity": "sha512-d5Mnn+ICbhoJPi37Ccjy+86T5qfH2+mSTrTMBXNPfB+wqSWOayBBLLIrCXdUy1YssluCVdGkl+DO9w5r5ODmUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.87.0.tgz", + "integrity": "sha512-sf5YWuUo1Dx9loqWCgF8P278MycfcD4JHjDC/LV5j11L3Lqa4PFiwCjD6B8kP4AjsgZZkLn3xbpJEtZBaccs4Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.87.0.tgz", + "integrity": "sha512-+aCraYbmK3uU3P0rFojjeaK9pGY6JvBDXh/0CM+gPyWKmNmXmnFe0qj4qj02U4ibFpbd9Pa9CmDvIdjJUd29rA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.87.0.tgz", + "integrity": "sha512-7rzbZQIljo6VLWVPqUyLa2P/WV3VFsJB4M52R4NCUJn1A50yjtUsHxiMHFL9opEKGFIiTbZ3VQzmtcJOPUq4NQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.87.0.tgz", + "integrity": "sha512-Dry46Fa+AXE/xt9J/YriujU1pU9mzfNWHGQwtOYibAFH8HLrKWCi93AhIGnuUMiwn2JbuBkRyz51f50Ly4r4ow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.87.0.tgz", + "integrity": "sha512-f/HMZez74gJvnRpGz+b5sdGyWrEgRLmFTXg9sNoqjPWogGxYHUhXKTKyMiYsu9rUGR2ciYqL3fk+Ua4JhMJbRA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.87.0.tgz", + "integrity": "sha512-lEC6ZfPZm/07JlB7FsOWX9o1RiHlXiBW594LSUGuXmekC8LZmvUPP9XzE2WZcNxJi5cHA/Fw56o02c3JxgMA5g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.87.0.tgz", + "integrity": "sha512-afBWKOD36dUiA9AenaiWpCg8xfyUVPYznyAAVXu413brK4RA5UI/Xoc4yfeGIT8G1a44MV+JR7hioryerBDzGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.87.0.tgz", + "integrity": "sha512-daptNsEOuru4/nJTxV4DBHNJ5GepzmaBVQf+jjgKQGi9NRlZKik+RFq4hUL7ewsF1pwjKM+SkYaMbF82UwFGnw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.87.0.tgz", + "integrity": "sha512-k+m/EShyXE29J/r1fjk0HlAzhN5JhIvj044oSJkXTEkCLrWZQgXb/3r4q5LTw/eaKW/4GuMXG2xmiJs3s5rPNw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", + "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", + "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", + "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", + "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", + "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", + "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", + "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", + "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", + "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", + "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", + "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", + "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", + "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", + "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", + "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tanstack/query-core": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.1.tgz", + "integrity": "sha512-TiRghcrGkUTE+M4JBxvpPSpBhxvIzQairw+cksrHYGr+2uwC77yW1SY9nYxnYRYOppvucf8rEg6b5byoz4rTpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.104.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.1.tgz", + "integrity": "sha512-Zz70EgjahNI7aCz8BZxM3vUGU44ycxEAzMuCUjLeA7SVhhOkg3ZgPjPJPvHjs6Rmky/dtmnu1WzjGHNHmhCoZQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.104.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/node": { + "version": "24.19.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz", + "integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", + "integrity": "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "oxc-transform-react": "^0.152.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "oxc-transform-react": { + "optional": true + } + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.52.0.tgz", + "integrity": "sha512-TUgPtl5ZI9WgxOcbu4ckaC5B3l1qxPQrCMgb6OfUB9owx/OvSJfcf93SMgUWAxRw5/1XDfh9uI5F5iNGXCcGDQ==", + "license": "ISC", + "peerDependencies": { + "@types/react": "*", + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/oxlint": { + "version": "1.87.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.87.0.tgz", + "integrity": "sha512-drxVlaW7Oqq7wSy1CN25znRu00aRq8n57Q2wniJRxCmtYVz07ufCm7EVDPQFZURrTgQIjzKrVEHAD/HcvJXObg==", + "dev": true, + "license": "MIT", + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.87.0", + "@oxlint/binding-android-arm64": "1.87.0", + "@oxlint/binding-darwin-arm64": "1.87.0", + "@oxlint/binding-darwin-x64": "1.87.0", + "@oxlint/binding-freebsd-x64": "1.87.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.87.0", + "@oxlint/binding-linux-arm-musleabihf": "1.87.0", + "@oxlint/binding-linux-arm64-gnu": "1.87.0", + "@oxlint/binding-linux-arm64-musl": "1.87.0", + "@oxlint/binding-linux-ppc64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-gnu": "1.87.0", + "@oxlint/binding-linux-riscv64-musl": "1.87.0", + "@oxlint/binding-linux-s390x-gnu": "1.87.0", + "@oxlint/binding-linux-x64-gnu": "1.87.0", + "@oxlint/binding-linux-x64-musl": "1.87.0", + "@oxlint/binding-openharmony-arm64": "1.87.0", + "@oxlint/binding-win32-arm64-msvc": "1.87.0", + "@oxlint/binding-win32-ia32-msvc": "1.87.0", + "@oxlint/binding-win32-x64-msvc": "1.87.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=7.0.2003", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.0" + } + }, + "node_modules/react-router": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.4.tgz", + "integrity": "sha512-PUPQcMhMGRAslLcvtlPz/kmzBEWPhLdgLFrL7pLNepBL6dX0lWj4WD2cUYVgYCuT3jxvghYFg81cDTj44DhetQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.4.tgz", + "integrity": "sha512-yrfmJHIpDG7taCpqKjT1G5B6q3O2K+RN8/fgNf0lTjCwiPbQ0ei6vXX9ZjQR+7ld8Tr7Z5xmyMnZ8YJrphWQUw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.4" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rolldown": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", + "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.152.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.12", + "@rolldown/binding-android-arm64": "1.2.12", + "@rolldown/binding-darwin-arm64": "1.2.12", + "@rolldown/binding-darwin-x64": "1.2.12", + "@rolldown/binding-freebsd-x64": "1.2.12", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", + "@rolldown/binding-linux-arm64-gnu": "1.2.12", + "@rolldown/binding-linux-arm64-musl": "1.2.12", + "@rolldown/binding-linux-ppc64-gnu": "1.2.12", + "@rolldown/binding-linux-s390x-gnu": "1.2.12", + "@rolldown/binding-linux-x64-gnu": "1.2.12", + "@rolldown/binding-linux-x64-musl": "1.2.12", + "@rolldown/binding-openharmony-arm64": "1.2.12", + "@rolldown/binding-win32-arm64-msvc": "1.2.12", + "@rolldown/binding-win32-x64-msvc": "1.2.12" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/sonner": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.8.tgz", + "integrity": "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.3.tgz", + "integrity": "sha512-cTAldKPImjg6c+gk48U19POPn3GCBzZwpdsN8ZMEEcbpes+6/wvfqUd0C2y3qYY4wsj8PwgFwrZ/1jBPVttMSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.28", + "rolldown": "~1.2.11", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f5e12d4 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-query": "^5.104.1", + "lucide-react": "^1.52.0", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router-dom": "^7.18.4", + "sonner": "^2.0.8" + }, + "devDependencies": { + "@types/node": "^24.13.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.7", + "@vitejs/plugin-react": "^6.1.1", + "oxlint": "^1.81.0", + "typescript": "~6.0.2", + "vite": "^8.3.0" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..9ca8e8d --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,122 @@ +import { useState } from 'react' +import heroImg from './assets/hero.png' +import reactLogo from './assets/react.svg' +import viteLogo from './assets/vite.svg' +import './App.css' + +function App() { + const [count, setCount] = useState(0) + + return ( + <> +
+
+ + React logo + Vite logo +
+
+

Get started

+

+ Edit src/App.tsx and save to test HMR +

+
+ +
+ +
+ +
+
+ +

Documentation

+

Your questions, answered

+ +
+
+ +

Connect with us

+

Join the Vite community

+ +
+
+ +
+
+ + ) +} + +export default App diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000000000000000000000000000000000000..02251f4b956c55af2d76fd0788124d7eee2b45eb GIT binary patch literal 13057 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 P00000NkvXXu0mjft=yBf literal 0 HcmV?d00001 diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..5fb3313 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,111 @@ +:root { + --text: #6b6375; + --text-h: #08060d; + --bg: #fff; + --border: #e5e4e7; + --code-bg: #f4f3ec; + --accent: #aa3bff; + --accent-bg: rgba(170, 59, 255, 0.1); + --accent-border: rgba(170, 59, 255, 0.5); + --social-bg: rgba(244, 243, 236, 0.5); + --shadow: + rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; + + --sans: system-ui, 'Segoe UI', Roboto, sans-serif; + --heading: system-ui, 'Segoe UI', Roboto, sans-serif; + --mono: ui-monospace, Consolas, monospace; + + font: 18px/145% var(--sans); + letter-spacing: 0.18px; + color-scheme: light dark; + color: var(--text); + background: var(--bg); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + @media (max-width: 1024px) { + font-size: 16px; + } +} + +@media (prefers-color-scheme: dark) { + :root { + --text: #9ca3af; + --text-h: #f3f4f6; + --bg: #16171d; + --border: #2e303a; + --code-bg: #1f2028; + --accent: #c084fc; + --accent-bg: rgba(192, 132, 252, 0.15); + --accent-border: rgba(192, 132, 252, 0.5); + --social-bg: rgba(47, 48, 58, 0.5); + --shadow: + rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; + } + + #social .button-icon { + filter: invert(1) brightness(2); + } +} + +#root { + width: 1126px; + max-width: 100%; + margin: 0 auto; + text-align: center; + border-inline: 1px solid var(--border); + min-height: 100svh; + display: flex; + flex-direction: column; + box-sizing: border-box; +} + +body { + margin: 0; +} + +h1, +h2 { + font-family: var(--heading); + font-weight: 500; + color: var(--text-h); +} + +h1 { + font-size: 56px; + letter-spacing: -1.68px; + margin: 32px 0; + @media (max-width: 1024px) { + font-size: 36px; + margin: 20px 0; + } +} +h2 { + font-size: 24px; + line-height: 118%; + letter-spacing: -0.24px; + margin: 0 0 8px; + @media (max-width: 1024px) { + font-size: 20px; + } +} +p { + margin: 0; +} + +code, +.counter { + font-family: var(--mono); + display: inline-flex; + border-radius: 4px; + color: var(--text-h); +} + +code { + font-size: 15px; + line-height: 135%; + padding: 4px 8px; + background: var(--code-bg); +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..6830b6f --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "allowArbitraryExtensions": true, + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8455dcb --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "module": "nodenext", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9982072 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,7 @@ +import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +}) From 2f297b4977fbc66645b08b19ef5c1012818fd253 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 7 Oct 2026 00:39:06 +0000 Subject: [PATCH 11/11] Build senior multAI workspace UI with mcoli components --- frontend/@/components/ui/button.tsx | 57 + frontend/@/components/ui/mc-alert.tsx | 85 + frontend/@/components/ui/mc-avatar.tsx | 112 + frontend/@/components/ui/mc-badge.tsx | 214 + frontend/@/components/ui/mc-button.tsx | 172 + frontend/@/components/ui/mc-card.tsx | 208 + frontend/@/components/ui/mc-checkbox.tsx | 134 + frontend/@/components/ui/mc-data-table.tsx | 642 ++ frontend/@/components/ui/mc-dialog.tsx | 145 + frontend/@/components/ui/mc-dropdown-menu.tsx | 267 + frontend/@/components/ui/mc-input.tsx | 253 + frontend/@/components/ui/mc-progress.tsx | 431 ++ frontend/@/components/ui/mc-select.tsx | 365 + frontend/@/components/ui/mc-separator.tsx | 38 + frontend/@/components/ui/mc-sidebar.tsx | 717 ++ frontend/@/components/ui/mc-skeleton.tsx | 24 + frontend/@/components/ui/mc-sonner.tsx | 80 + frontend/@/components/ui/mc-tabs.tsx | 53 + frontend/@/components/ui/mc-textarea.tsx | 147 + frontend/@/components/ui/mc-tooltip.tsx | 92 + frontend/@/lib/utils.ts | 1 + frontend/components.json | 25 + frontend/package-lock.json | 6575 ++++++++++++++--- frontend/package.json | 13 +- frontend/src/App.css | 197 +- frontend/src/App.tsx | 204 +- frontend/src/index.css | 526 +- frontend/tsconfig.app.json | 3 + frontend/vite.config.ts | 10 +- 29 files changed, 10446 insertions(+), 1344 deletions(-) create mode 100644 frontend/@/components/ui/button.tsx create mode 100644 frontend/@/components/ui/mc-alert.tsx create mode 100644 frontend/@/components/ui/mc-avatar.tsx create mode 100644 frontend/@/components/ui/mc-badge.tsx create mode 100644 frontend/@/components/ui/mc-button.tsx create mode 100644 frontend/@/components/ui/mc-card.tsx create mode 100644 frontend/@/components/ui/mc-checkbox.tsx create mode 100644 frontend/@/components/ui/mc-data-table.tsx create mode 100644 frontend/@/components/ui/mc-dialog.tsx create mode 100644 frontend/@/components/ui/mc-dropdown-menu.tsx create mode 100644 frontend/@/components/ui/mc-input.tsx create mode 100644 frontend/@/components/ui/mc-progress.tsx create mode 100644 frontend/@/components/ui/mc-select.tsx create mode 100644 frontend/@/components/ui/mc-separator.tsx create mode 100644 frontend/@/components/ui/mc-sidebar.tsx create mode 100644 frontend/@/components/ui/mc-skeleton.tsx create mode 100644 frontend/@/components/ui/mc-sonner.tsx create mode 100644 frontend/@/components/ui/mc-tabs.tsx create mode 100644 frontend/@/components/ui/mc-textarea.tsx create mode 100644 frontend/@/components/ui/mc-tooltip.tsx create mode 100644 frontend/@/lib/utils.ts create mode 100644 frontend/components.json diff --git a/frontend/@/components/ui/button.tsx b/frontend/@/components/ui/button.tsx new file mode 100644 index 0000000..997e0c7 --- /dev/null +++ b/frontend/@/components/ui/button.tsx @@ -0,0 +1,57 @@ +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" +import { cn } from "cn" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + ...props +}: ButtonPrimitive.Props & VariantProps) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/frontend/@/components/ui/mc-alert.tsx b/frontend/@/components/ui/mc-alert.tsx new file mode 100644 index 0000000..bebb717 --- /dev/null +++ b/frontend/@/components/ui/mc-alert.tsx @@ -0,0 +1,85 @@ +'use client'; + +import * as React from 'react'; +import { CircleAlert, CircleCheck, Popcorn } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +type AlertVariant = 'success' | 'default' | 'destructive'; + +interface McAlertProps extends React.HTMLAttributes { + variant: AlertVariant; + title: string; + description?: string; + items?: string[]; +} + +const variantStyles: Record = { + success: + 'border border-success rounded-lg w-fit max-w-[719px] pt-[12px] pb-[14px] pl-[12px] pr-[12px]', + default: 'border border-border rounded-lg w-fit max-w-[719px] pl-[16px] pr-3 py-3', + destructive: 'border border-destructive rounded-lg w-fit max-w-[719px] pl-[16px] pr-4 py-3', +}; + +const titleStyles: Record = { + success: 'text-success font-medium text-base leading-6', + default: 'text-card-foreground font-medium text-base leading-6', + destructive: 'text-destructive font-medium text-base leading-6', +}; + +const descStyles: Record = { + success: 'text-success font-normal text-sm leading-5', + default: 'text-muted-foreground font-normal text-sm leading-5', + destructive: 'text-destructive font-normal text-sm leading-5', +}; + +const iconMap: Record = { + success: , + default: , + destructive: , +}; + +const iconGap: Record = { + success: 'gap-1', + default: 'gap-3', + destructive: 'gap-3', +}; + +function McAlert({ variant, title, description, items, className, ...props }: McAlertProps) { + const hasSupportingContent = !!description || !!items?.length; + + return ( +
+
+
{iconMap[variant]}
+
+ {title} + + {description &&

{description}

} + + {items && items.length > 0 && ( +
    + {items.map((item, i) => ( +
  • {item}
  • + ))} +
+ )} +
+
+
+ ); +} + +export { McAlert }; +export type { McAlertProps, AlertVariant }; diff --git a/frontend/@/components/ui/mc-avatar.tsx b/frontend/@/components/ui/mc-avatar.tsx new file mode 100644 index 0000000..7f38561 --- /dev/null +++ b/frontend/@/components/ui/mc-avatar.tsx @@ -0,0 +1,112 @@ +'use client'; + +import * as React from 'react'; +import { Avatar as AvatarPrimitive } from '@base-ui/react/avatar'; + +import { cn } from '@/lib/utils'; + +function McAvatar({ + className, + size = 'default', + ...props +}: AvatarPrimitive.Root.Props & { + size?: 'default' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; +}) { + return ( + + ); +} + +function McAvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) { + return ( + + ); +} + +function McAvatarFallback({ className, ...props }: AvatarPrimitive.Fallback.Props) { + return ( + + ); +} + +function McAvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + svg]:hidden', + 'group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden', + 'group-data-[size=md]/avatar:size-2.5 group-data-[size=md]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + 'group-data-[size=xl]/avatar:size-3.5 group-data-[size=xl]/avatar:[&>svg]:size-2.5', + 'group-data-[size=2xl]/avatar:size-4 group-data-[size=2xl]/avatar:[&>svg]:size-3', + 'not-empty:group-data-[size=xs]/avatar:size-2.5 group-data-[size=xs]/avatar:[&>svg]:hidden', + 'not-empty:group-data-[size=sm]/avatar:size-3 group-data-[size=sm]/avatar:[&>svg]:hidden', + 'not-empty:group-data-[size=md]/avatar:size-3.5 group-data-[size=md]/avatar:[&>svg]:size-2.5', + 'not-empty:group-data-[size=lg]/avatar:size-4 group-data-[size=lg]/avatar:[&>svg]:size-2.5', + 'not-empty:group-data-[size=xl]/avatar:size-4.5 group-data-[size=xl]/avatar:[&>svg]:size-3', + 'not-empty:group-data-[size=2xl]/avatar:size-5 group-data-[size=2xl]/avatar:[&>svg]:size-3.5', + className + )} + {...props} + /> + ); +} + +function McAvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function McAvatarGroupCount({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className + )} + {...props} + /> + ); +} + +export { + McAvatar, + McAvatarImage, + McAvatarFallback, + McAvatarGroup, + McAvatarGroupCount, + McAvatarBadge, +}; diff --git a/frontend/@/components/ui/mc-badge.tsx b/frontend/@/components/ui/mc-badge.tsx new file mode 100644 index 0000000..8285596 --- /dev/null +++ b/frontend/@/components/ui/mc-badge.tsx @@ -0,0 +1,214 @@ +import { mergeProps } from '@base-ui/react/merge-props'; +import { useRender } from '@base-ui/react/use-render'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type { ReactNode } from 'react'; +import { cloneElement, isValidElement } from 'react'; + +import { cn } from '@/lib/utils'; + +const McBadgeVariants = cva( + 'group/badge inline-flex shrink-0 items-center justify-center gap-1 overflow-hidden rounded-[16px] border border-[#E6E9FF] text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:size-3 [&>svg]:shrink-0 [&>svg]:self-center', + { + variants: { + variant: { + default: 'bg-primary-foreground text-primary [a]:hover:bg-primary-foreground/80', + secondary: 'bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80', + destructive: + 'bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20', + outline: 'border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground', + ghost: 'hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50', + leading: 'bg-accent-foreground text-accent', + }, + size: { + sm: 'h-[22px] min-w-[47px] px-2 py-0.5 text-sm leading-[18px]', + md: 'h-[24px] min-w-[56px] px-[10px] py-0.5 text-sm leading-[20px]', + lg: 'h-[32px] min-w-[65px] px-3 py-1 text-sm leading-[24px]', + groupMd: 'h-[22px] min-w-[47px] px-2 py-0.5 text-sm leading-[18px]', + groupLg: 'h-[28px] min-w-[56px] px-[10px] py-1 text-sm leading-[20px]', + }, + }, + defaultVariants: { + variant: 'default', + size: 'sm', + }, + } +); + +function McBadge({ + className, + variant = 'default', + size = 'sm', + render, + icon, + iconPosition = 'start', + iconOnly = false, + image, + imageAlt = '', + imagePosition = 'start', + leadingBadge, + leadingBadgePosition = 'start', + leadingBadgeIcon, + groupSize = 'md', + children, + ...props +}: useRender.ComponentProps<'span'> & + VariantProps & { + icon?: ReactNode; + iconPosition?: 'start' | 'end'; + iconOnly?: boolean; + image?: string; + imageAlt?: string; + imagePosition?: 'start' | 'end'; + leadingBadge?: ReactNode; + leadingBadgePosition?: 'start' | 'end'; + leadingBadgeIcon?: ReactNode; + groupSize?: 'md' | 'lg'; + }) { + const groupSizeClasses = { + md: 'h-[30px] leading-[22px]', + lg: 'h-[36px] leading-[26px]', + } as const; + const groupChildSize = groupSize === 'md' ? 'groupMd' : 'groupLg'; + + const imageEl = image ? ( + {imageAlt} + ) : null; + + const useParentIcon = !leadingBadge || leadingBadgePosition === 'start'; + const rawIcon = useParentIcon ? icon : undefined; + const parentIcon = rawIcon ? ( + + + {rawIcon} + + + ) : undefined; + + const parentIconPlacement = parentIcon + ? iconPosition === 'end' + ? 'inline-end' + : 'inline-start' + : undefined; + + const contentWithIcon = parentIcon ? ( + iconPosition === 'end' ? ( + <> + {children} + {parentIcon} + + ) : ( + <> + {parentIcon} + {children} + + ) + ) : ( + children + ); + + const content = imageEl ? ( + imagePosition === 'end' ? ( + <> + {contentWithIcon} + {imageEl} + + ) : ( + <> + {imageEl} + {contentWithIcon} + + ) + ) : ( + contentWithIcon + ); + + const resolvedLeadingBadge = leadingBadge ? ( + isValidElement(leadingBadge) ? ( + cloneElement(leadingBadge as React.ReactElement>, { + size: groupChildSize, + variant: 'leading', // ← forcé + icon: leadingBadgePosition === 'end' ? leadingBadgeIcon : undefined, + iconPosition: 'end', + }) + ) : ( + + {leadingBadge} + + ) + ) : null; + + const groupedContent = resolvedLeadingBadge ? ( + leadingBadgePosition === 'end' ? ( +
+ {content} + {resolvedLeadingBadge} +
+ ) : ( +
+ {resolvedLeadingBadge} + {content} +
+ ) + ) : ( + content + ); + + const dataIconProps = parentIconPlacement + ? ({ 'data-icon': parentIconPlacement } as Record) + : undefined; + + const iconOnlyClasses = iconOnly ? 'min-w-0 px-1.5' : undefined; + + const imagePaddingClasses = image + ? imagePosition === 'start' + ? 'pl-[3px]' + : 'pr-[3px]' + : undefined; + + const groupPaddingClasses = resolvedLeadingBadge + ? leadingBadgePosition === 'start' + ? 'pl-1 pr-[10px]' + : 'pl-[10px] pr-1' + : undefined; + + return useRender({ + defaultTagName: 'span', + props: mergeProps<'span'>( + { + className: cn( + McBadgeVariants({ variant, size }), + leadingBadge ? groupSizeClasses[groupSize] : undefined, + iconOnlyClasses, + imagePaddingClasses, + groupPaddingClasses, + className + ), + ...(dataIconProps ?? {}), + children: groupedContent, + }, + props + ), + render, + state: { + slot: 'badge', + variant, + size, + }, + }); +} + +export { McBadge, McBadgeVariants }; diff --git a/frontend/@/components/ui/mc-button.tsx b/frontend/@/components/ui/mc-button.tsx new file mode 100644 index 0000000..9624e93 --- /dev/null +++ b/frontend/@/components/ui/mc-button.tsx @@ -0,0 +1,172 @@ +'use client'; + +import * as React from 'react'; +import { Button as ButtonPrimitive } from '@base-ui/react/button'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { LinkIcon, Loader2 } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const buttonVariants = cva( + 'group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding whitespace-nowrap transition-all outline-none select-none disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0', + { + variants: { + variant: { + primary: + 'bg-primary text-primary-foreground hover:bg-secondary-foreground hover:text-secondary active:bg-secondary-foreground active:text-secondary active:ring-4 active:ring-secondary disabled:bg-muted disabled:text-muted-foreground', + secondary: + 'bg-secondary text-secondary-foreground hover:bg-accent hover:text-accent-foreground active:bg-accent active:text-accent-foreground active:ring-4 active:ring-secondary disabled:bg-muted disabled:text-muted-foreground', + tertiary: + 'bg-primary-foreground text-primary hover:text-accent-foreground active:text-accent-foreground active:ring-4 active:ring-secondary disabled:bg-muted disabled:text-muted-foreground', + link: 'bg-transparent p-0 text-foreground underline hover:text-accent-foreground active:text-accent-foreground disabled:text-muted-foreground', + }, + size: { + sm: 'px-3.5 py-2 text-sm font-medium', + md: 'px-4 py-2.5 text-sm font-medium', + lg: 'px-[1.125rem] py-2.5 text-base font-medium', + xl: 'px-5 py-3 text-base font-medium', + }, + icon: { + none: '', + leading: 'gap-2', + trailing: 'gap-2', + dot: 'gap-2', + only: '', + }, + destructive: { + true: '', + false: '', + }, + }, + compoundVariants: [ + { + variant: 'link', + className: 'px-0 py-0', + }, + { + icon: 'only', + size: 'sm', + className: 'p-2', + }, + { + icon: 'only', + size: 'md', + className: 'p-2.5', + }, + { + icon: 'only', + size: 'lg', + className: 'p-3', + }, + { + icon: 'only', + size: 'xl', + className: 'p-3.5', + }, + // Destructive overrides + { + destructive: true, + variant: 'primary', + className: + 'bg-destructive text-destructive-foreground hover:bg-destructive/90 active:bg-destructive/90 active:ring-4 active:ring-destructive/20', + }, + { + destructive: true, + variant: 'secondary', + className: + 'bg-destructive/10 text-destructive hover:bg-destructive/20 active:ring-4 active:ring-destructive/20', + }, + { + destructive: true, + variant: 'tertiary', + className: + 'text-destructive hover:bg-destructive/10 active:ring-4 active:ring-destructive/20', + }, + { + destructive: true, + variant: 'link', + className: 'text-destructive hover:text-destructive/80 active:text-destructive/80', + }, + ], + defaultVariants: { + variant: 'primary', + size: 'md', + icon: 'none', + destructive: false, + }, + } +); + +export interface McButtonProps + extends Omit, Omit, 'icon'> { + iconDefinition?: React.ReactNode; + icon?: 'none' | 'leading' | 'trailing' | 'dot' | 'only'; + isLoading?: boolean; +} + +function McButton({ + className, + variant = 'primary', + size, + icon = 'none', + destructive, + iconDefinition, + isLoading, + children, + disabled, + ...props +}: McButtonProps) { + const isLink = variant === 'link'; + const effectiveIcon = + isLink && (icon === 'leading' || icon === 'trailing') && !iconDefinition ? ( + + ) : ( + iconDefinition + ); + + return ( + + {isLoading ? ( + + ) : ( + <> + {icon === 'dot' && ( + + )} + {icon === 'leading' && effectiveIcon && ( + + {effectiveIcon} + + )} + + )} + + {icon !== 'only' && children} + {icon === 'only' && !isLoading && effectiveIcon} + {/* Icon-only buttons keep their children as the accessible name. */} + {icon === 'only' && children ? {children} : null} + + {icon === 'trailing' && !isLoading && effectiveIcon && ( + + {effectiveIcon} + + )} + + ); +} + +export { McButton, buttonVariants }; diff --git a/frontend/@/components/ui/mc-card.tsx b/frontend/@/components/ui/mc-card.tsx new file mode 100644 index 0000000..9f8f56b --- /dev/null +++ b/frontend/@/components/ui/mc-card.tsx @@ -0,0 +1,208 @@ +'use client'; + +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; + +import { cn } from '@/lib/utils'; + +const McCardDirectionContext = React.createContext<{ direction: 'row' | 'column' } | null>(null); + +const cardVariants = cva( + 'flex gap-4 rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm w-full', + { + variants: { + direction: { + row: 'flex-row', + column: 'flex-col', + }, + }, + defaultVariants: { + direction: 'column', + }, + } +); + +function McCard({ + className, + direction = 'column', + children, + ...props +}: React.ComponentProps<'div'> & { direction?: 'row' | 'column' }) { + return ( + +
+ {children} +
+
+ ); +} + +export interface McCardHeaderProps extends React.ComponentProps<'div'> { + title: string; + description?: string; +} + +function McCardHeader({ className, title, description, ...props }: McCardHeaderProps) { + return ( +
+

{title}

+ {description &&

{description}

} +
+ ); +} + +const footerVariants = cva('flex gap-2', { + variants: { + cardDirection: { + row: 'self-stretch ms-auto', + column: '', + }, + direction: { + row: 'flex-row', + column: 'flex-col', + }, + align: { + start: '', + end: '', + center: '', + stretch: '', + }, + }, + compoundVariants: [ + // cardDirection: column: matches previous footer-only (direction × align) behavior + { + cardDirection: 'column', + direction: 'row', + align: 'start', + className: 'items-center justify-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'row', + align: 'end', + className: 'items-center justify-end [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'row', + align: 'center', + className: 'items-center justify-center [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'row', + align: 'stretch', + className: 'items-center justify-stretch *:min-w-0 *:flex-1', + }, + { + cardDirection: 'column', + direction: 'column', + align: 'start', + className: 'items-start justify-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'column', + align: 'end', + className: 'items-end justify-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'column', + align: 'center', + className: 'items-center justify-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'column', + direction: 'column', + align: 'stretch', + className: 'items-stretch justify-start *:min-h-0 *:flex-1', + }, + // cardDirection: row: horizontal card; footer sits beside header/body, align controls vertical position + // footer direction: row → children horizontal, align maps to cross-axis (items-*) + { + cardDirection: 'row', + direction: 'row', + align: 'start', + className: 'items-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'row', + align: 'end', + className: 'items-end [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'row', + align: 'center', + className: 'items-center [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'row', + align: 'stretch', + className: 'items-stretch *:min-w-0 *:flex-1', + }, + // footer direction: column → children vertical, align maps to main-axis (justify-*) + { + cardDirection: 'row', + direction: 'column', + align: 'start', + className: 'justify-start [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'column', + align: 'end', + className: 'justify-end [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'column', + align: 'center', + className: 'justify-center [&>*]:shrink-0 [&>*]:grow-0', + }, + { + cardDirection: 'row', + direction: 'column', + align: 'stretch', + className: 'items-stretch justify-start *:min-h-0 *:flex-1', + }, + ], + defaultVariants: { + cardDirection: 'column', + direction: 'row', + align: 'stretch', + }, +}); + +export interface McCardFooterProps + extends React.ComponentProps<'div'>, VariantProps {} + +function McCardFooter({ + className, + direction, + align, + cardDirection: cardDirectionProp, + ...props +}: McCardFooterProps) { + const ctx = React.useContext(McCardDirectionContext); + const cardDirection = cardDirectionProp ?? ctx?.direction ?? 'column'; + + return ( +
+ ); +} + +export type McCardContentProps = React.ComponentProps<'div'>; + +function McCardContent({ className, ...props }: McCardContentProps) { + return
; +} + +export { McCard, McCardHeader, McCardFooter, McCardContent }; diff --git a/frontend/@/components/ui/mc-checkbox.tsx b/frontend/@/components/ui/mc-checkbox.tsx new file mode 100644 index 0000000..d498717 --- /dev/null +++ b/frontend/@/components/ui/mc-checkbox.tsx @@ -0,0 +1,134 @@ +'use client'; + +import * as React from 'react'; +import { Checkbox as CheckboxPrimitive } from '@base-ui/react/checkbox'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { CheckIcon, MinusIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +const checkboxVariants = cva( + 'peer relative flex shrink-0 items-center justify-center rounded-[4px] border transition-colors outline-none disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50', + { + variants: { + size: { + sm: 'size-4 [&_[data-slot=checkbox-indicator]>svg]:size-3', + md: 'size-5 [&_[data-slot=checkbox-indicator]>svg]:size-3.5', + }, + }, + defaultVariants: { + size: 'sm', + }, + } +); + +const textVariants = cva('font-medium transition-colors', { + variants: { + size: { + sm: 'text-sm', + md: 'text-base', + }, + disabled: { + true: 'text-muted-foreground', + false: 'text-foreground', + }, + }, + defaultVariants: { + size: 'sm', + disabled: false, + }, +}); + +const supportTextVariants = cva('font-regular', { + variants: { + size: { + sm: 'text-sm', + md: 'text-base', + }, + }, + defaultVariants: { + size: 'sm', + }, +}); + +export interface McCheckboxProps + extends CheckboxPrimitive.Root.Props, VariantProps { + text?: string; + supportText?: string; +} + +function McCheckbox({ + className, + size = 'sm', + text, + supportText, + id, + disabled, + checked, + onCheckedChange, + ...props +}: McCheckboxProps) { + const checkboxId = React.useId(); + const finalId = id ?? checkboxId; + const labelId = text ? `${finalId}-label` : undefined; + const descriptionId = supportText ? `${finalId}-description` : undefined; + + return ( + + ); +} + +export { McCheckbox }; diff --git a/frontend/@/components/ui/mc-data-table.tsx b/frontend/@/components/ui/mc-data-table.tsx new file mode 100644 index 0000000..64437ea --- /dev/null +++ b/frontend/@/components/ui/mc-data-table.tsx @@ -0,0 +1,642 @@ +'use client'; + +import * as React from 'react'; +import { + columnFilteringFeature, + createColumnHelper, + createFilteredRowModel, + createPaginatedRowModel, + createSortedRowModel, + filterFn_includesString, + flexRender, + globalFilteringFeature, + metaHelper, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFn_alphanumeric, + sortFn_basic, + sortFn_datetime, + sortFn_text, + tableFeatures, + useTable, + type ColumnDef, + type Header, + type OnChangeFn, + type RowData, + type RowSelectionState, + type Table, +} from '@tanstack/react-table'; +import { + ArrowDownIcon, + ArrowLeftIcon, + ArrowRightIcon, + ArrowUpIcon, + HelpCircleIcon, +} from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { McButton } from '@/components/ui/mc-button'; +import { McCheckbox } from '@/components/ui/mc-checkbox'; +import { McTooltip, McTooltipContent, McTooltipTrigger } from '@/components/ui/mc-tooltip'; + +// table primitives + +function McTable({ className, ...props }: React.ComponentProps<'table'>) { + return ( +
+ + + ); +} + +function McTableHeader({ className, ...props }: React.ComponentProps<'thead'>) { + return ( + + ); +} + +function McTableBody({ className, ...props }: React.ComponentProps<'tbody'>) { + return ( + + ); +} + +function McTableRow({ className, ...props }: React.ComponentProps<'tr'>) { + return ( + + ); +} + +function McTableHead({ className, ...props }: React.ComponentProps<'th'>) { + return ( +
+ ); +} + +function McTableCell({ className, ...props }: React.ComponentProps<'td'>) { + return ( + + ); +} + +function McTableCaption({ className, ...props }: React.ComponentProps<'caption'>) { + return ( +
+ ); +} + +// card chrome + +function McDataTableCard({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function McDataTableHeader({ + className, + title, + badge, + description, + actions, + ...props +}: Omit, 'title'> & { + title: React.ReactNode; + badge?: React.ReactNode; + description?: React.ReactNode; + actions?: React.ReactNode; +}) { + return ( +
+
+
+

{title}

+ {badge && ( + + {badge} + + )} +
+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +function McDataTableToolbar({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function McDataTableEmpty({ + className, + icon, + title, + description, + actions, + ...props +}: Omit, 'title'> & { + icon?: React.ReactNode; + title: React.ReactNode; + description?: React.ReactNode; + actions?: React.ReactNode; +}) { + return ( +
+ {icon && ( +
+ {icon} +
+ )} +
+

{title}

+ {description &&

{description}

} +
+ {actions &&
{actions}
} +
+ ); +} + +function McDataTableCellUser({ + className, + avatar, + title, + subtitle, + ...props +}: Omit, 'title'> & { + avatar?: React.ReactNode; + title: React.ReactNode; + subtitle?: React.ReactNode; +}) { + return ( +
+ {avatar} +
+ {title} + {subtitle && {subtitle}} +
+
+ ); +} + +// table logic + +export interface McDataTableColumnMeta { + headerTooltip?: string; + headerClassName?: string; + cellClassName?: string; +} + +const mcDataTableFeatures = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns: { + alphanumeric: sortFn_alphanumeric, + basic: sortFn_basic, + datetime: sortFn_datetime, + text: sortFn_text, + }, + columnFilteringFeature, + globalFilteringFeature, + filteredRowModel: createFilteredRowModel(), + filterFns: { includesString: filterFn_includesString }, + rowSelectionFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), + columnMeta: metaHelper(), +}); + +type McDataTableFeatures = typeof mcDataTableFeatures; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type McDataTableColumnDef = ColumnDef; + +function createMcDataTableColumnHelper() { + return createColumnHelper(); +} + +type McDataTablePaginationVariant = 'numbered' | 'compact' | 'minimal'; + +function McDataTableColumnHeader({ + header, +}: { + header: Header; +}) { + const { column } = header; + const label = flexRender(column.columnDef.header, header.getContext()); + const tooltip = column.columnDef.meta?.headerTooltip; + const sorted = column.getIsSorted(); + + const help = tooltip && ( + + + + + + + ); + + if (!column.getCanSort()) { + return ( + + {label} + {help} + + ); + } + + const SortIcon = sorted === 'asc' ? ArrowUpIcon : ArrowDownIcon; + + return ( + + + {help} + + ); +} + +function getPageItems(page: number, pageCount: number): (number | 'ellipsis')[] { + if (pageCount <= 7) { + return Array.from({ length: pageCount }, (_, i) => i + 1); + } + if (page <= 3 || page >= pageCount - 2) { + return [1, 2, 3, 'ellipsis', pageCount - 2, pageCount - 1, pageCount]; + } + return [1, 'ellipsis', page - 1, page, page + 1, 'ellipsis', pageCount]; +} + +function McDataTablePagination({ + table, + variant = 'numbered', + className, +}: { + table: Table; + variant?: McDataTablePaginationVariant; + className?: string; +}) { + const page = table.atoms.pagination.get().pageIndex + 1; + const pageCount = Math.max(table.getPageCount(), 1); + const canPrevious = table.getCanPreviousPage(); + const canNext = table.getCanNextPage(); + + const pageLabel = ( + + Page {page} of {pageCount} + + ); + + if (variant === 'minimal') { + return ( +
+ } + aria-label="Go to previous page" + disabled={!canPrevious} + onClick={() => table.previousPage()} + /> + {pageLabel} + } + aria-label="Go to next page" + disabled={!canNext} + onClick={() => table.nextPage()} + /> +
+ ); + } + + if (variant === 'compact') { + return ( +
+ {pageLabel} +
+ table.previousPage()} + > + Previous + + table.nextPage()} + > + Next + +
+
+ ); + } + + return ( + + ); +} + +export interface McDataTableProps { + columns: McDataTableColumnDef[]; + data: TData[]; + getRowId?: (row: TData, index: number) => string; + enableRowSelection?: boolean; + rowSelection?: RowSelectionState; + onRowSelectionChange?: OnChangeFn; + globalFilter?: string; + pageSize?: number; + pagination?: McDataTablePaginationVariant | false; + empty?: React.ReactNode; + className?: string; +} + +function McDataTable({ + columns, + data, + getRowId, + enableRowSelection = false, + rowSelection, + onRowSelectionChange, + globalFilter, + pageSize = 10, + pagination = 'numbered', + empty, + className, +}: McDataTableProps) { + const table = useTable({ + features: mcDataTableFeatures, + columns, + data, + getRowId, + enableRowSelection, + enableRowRangeSelection: false, + globalFilterFn: 'includesString', + manualPagination: pagination === false, + initialState: { pagination: { pageIndex: 0, pageSize } }, + state: { + ...(globalFilter !== undefined && { globalFilter }), + ...(rowSelection !== undefined && { rowSelection }), + }, + ...(onRowSelectionChange && { onRowSelectionChange }), + }); + + const rows = table.getRowModel().rows; + const columnCount = table.getAllLeafColumns().length; + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header, index) => { + const sorted = header.column.getIsSorted(); + return ( + +
+ {enableRowSelection && index === 0 && ( + table.toggleAllPageRowsSelected(checked)} + /> + )} + {!header.isPlaceholder && } +
+
+ ); + })} +
+ ))} +
+ + {rows.length > 0 ? ( + rows.map((row) => ( + + {row.getAllCells().map((cell, index) => ( + + {enableRowSelection && index === 0 ? ( +
+ row.toggleSelected(checked)} + /> + {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+ ) : ( + flexRender(cell.column.columnDef.cell, cell.getContext()) + )} +
+ ))} +
+ )) + ) : ( + + + {empty ?? } + + + )} +
+
+ {pagination && ( + + )} +
+ ); +} + +export { + McTable, + McTableHeader, + McTableBody, + McTableRow, + McTableHead, + McTableCell, + McTableCaption, + McDataTable, + McDataTableCard, + McDataTableHeader, + McDataTableToolbar, + McDataTableEmpty, + McDataTableCellUser, + McDataTableColumnHeader, + McDataTablePagination, + mcDataTableFeatures, + createMcDataTableColumnHelper, + type McDataTableFeatures, + type McDataTableColumnDef, + type McDataTablePaginationVariant, +}; diff --git a/frontend/@/components/ui/mc-dialog.tsx b/frontend/@/components/ui/mc-dialog.tsx new file mode 100644 index 0000000..d680dad --- /dev/null +++ b/frontend/@/components/ui/mc-dialog.tsx @@ -0,0 +1,145 @@ +'use client'; + +import * as React from 'react'; +import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'; +import { XIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; +import { McButton } from '@/components/ui/mc-button'; + +function McDialog({ ...props }: DialogPrimitive.Root.Props) { + return ; +} + +function McDialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) { + return ; +} + +function McDialogPortal({ ...props }: DialogPrimitive.Portal.Props) { + return ; +} + +function McDialogClose({ ...props }: DialogPrimitive.Close.Props) { + return ; +} + +function McDialogOverlay({ className, ...props }: DialogPrimitive.Backdrop.Props) { + return ( + + ); +} + +function McDialogContent({ + className, + children, + showCloseButton = true, + ...props +}: DialogPrimitive.Popup.Props & { + showCloseButton?: boolean; +}) { + return ( + + + + {children} + {showCloseButton && ( + } + > + Close + + } + /> + )} + + + ); +} + +function McDialogHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function McDialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<'div'> & { + showCloseButton?: boolean; +}) { + return ( +
+ {children} + {showCloseButton && ( + Close} /> + )} +
+ ); +} + +function McDialogTitle({ className, ...props }: DialogPrimitive.Title.Props) { + return ( + + ); +} + +function McDialogDescription({ className, ...props }: DialogPrimitive.Description.Props) { + return ( + + ); +} + +export { + McDialog, + McDialogClose, + McDialogContent, + McDialogDescription, + McDialogFooter, + McDialogHeader, + McDialogOverlay, + McDialogPortal, + McDialogTitle, + McDialogTrigger, +}; diff --git a/frontend/@/components/ui/mc-dropdown-menu.tsx b/frontend/@/components/ui/mc-dropdown-menu.tsx new file mode 100644 index 0000000..112077d --- /dev/null +++ b/frontend/@/components/ui/mc-dropdown-menu.tsx @@ -0,0 +1,267 @@ +'use client'; + +import * as React from 'react'; +import { Menu as MenuPrimitive } from '@base-ui/react/menu'; +import { CheckIcon, ChevronRightIcon } from 'lucide-react'; + +import { cn } from '@/lib/utils'; + +function McDropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function McDropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function McDropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function McDropdownMenuContent({ + align = 'start', + alignOffset = 0, + side = 'bottom', + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +function McDropdownMenuGroup({ + className, + ...props +}: MenuPrimitive.Group.Props & { className?: string }) { + return ( + + ); +} + +function McDropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function McDropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: 'default' | 'destructive'; +}) { + return ( + + ); +} + +function McDropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function McDropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function McDropdownMenuSubContent({ + align = 'start', + alignOffset = -3, + side = 'right', + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function McDropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function McDropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function McDropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function McDropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function McDropdownMenuShortcut({ className, ...props }: React.ComponentProps<'span'>) { + return ( + + ); +} + +export { + McDropdownMenu, + McDropdownMenuPortal, + McDropdownMenuTrigger, + McDropdownMenuContent, + McDropdownMenuGroup, + McDropdownMenuLabel, + McDropdownMenuItem, + McDropdownMenuCheckboxItem, + McDropdownMenuRadioGroup, + McDropdownMenuRadioItem, + McDropdownMenuSeparator, + McDropdownMenuShortcut, + McDropdownMenuSub, + McDropdownMenuSubTrigger, + McDropdownMenuSubContent, +}; diff --git a/frontend/@/components/ui/mc-input.tsx b/frontend/@/components/ui/mc-input.tsx new file mode 100644 index 0000000..7b2c090 --- /dev/null +++ b/frontend/@/components/ui/mc-input.tsx @@ -0,0 +1,253 @@ +'use client'; + +import * as React from 'react'; +import { Input as InputPrimitive } from '@base-ui/react/input'; + +import { cn } from '@/lib/utils'; + +export interface McInputProps extends React.ComponentProps<'input'> { + /** Label rendered above the input. */ + label?: React.ReactNode; + /** Help text below the input. Replaced by error when present. */ + description?: React.ReactNode; + /** Truthy = error state. ReactNode = renders the message. `true` = red border only. */ + error?: React.ReactNode; + /** Inline-start addon (icons, text, dropdowns). */ + addonStart?: React.ReactNode; + /** Inline-end addon (buttons, badges, spinners). */ + addonEnd?: React.ReactNode; + /** Block-start addon (toolbar, controls above the input row). */ + addonTop?: React.ReactNode; + /** Block-end addon (character count, status bar below the input row). */ + addonBottom?: React.ReactNode; +} + +function McInput({ + className, + label, + description, + error, + addonStart, + addonEnd, + addonTop, + addonBottom, + type, + id, + disabled, + required, + ...props +}: McInputProps) { + const generatedId = React.useId(); + const inputId = id ?? generatedId; + + const hasError = !!error; + const errorMessage = typeof error === 'boolean' ? null : error; + const showError = hasError && !!errorMessage; + const showDescription = !hasError && !!description; + const messageId = showError + ? `${inputId}-error` + : showDescription + ? `${inputId}-description` + : undefined; + + const handleGroupClick = (e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (!target.closest('button, a, input, select, textarea, [tabindex]')) { + e.currentTarget.querySelector('[data-slot="input"]')?.focus(); + } + }; + + return ( +
+ {label && ( + + )} + +
+ {addonTop && ( +
+ {addonTop} +
+ )} + + {/* + has-data-attached:items-stretch: when an attached McInputButton is + anywhere inside this row, switch from center to stretch so the addon + container fills the full row height and the button can match it. + */} +
+ {addonStart && ( +
svg:first-child):pl-3.5: widen padding only when the edge element is an icon + // has-data-attached:pl-0: flush for attached buttons (attribute selector beats type selector) + // has-data-attached:items-stretch: let attached button fill full height + 'group/addon flex shrink-0 items-center gap-2 pl-2 text-sm text-muted-foreground select-none [&:has(>svg:first-child)]:pl-3.5 has-data-attached:items-stretch has-data-attached:pl-0', + "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0" + )} + > + {addonStart} +
+ )} + + + + {addonEnd && ( +
svg:last-child)]:pr-3.5 has-data-attached:items-stretch has-data-attached:pr-0', + "[&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0" + )} + > + {addonEnd} +
+ )} +
+ + {addonBottom && ( +
+ {addonBottom} +
+ )} +
+ + {showError && ( + + )} + + {showDescription && ( +

+ {description} +

+ )} +
+ ); +} + +export interface McInputButtonProps extends React.ComponentProps<'button'> { + /** Visual style of the button. `attached` extends to the input edge and shares its border. */ + variant?: 'ghost' | 'outline' | 'filled' | 'attached'; +} + +function McInputButton({ + className, + variant = 'ghost', + type = 'button', + ...props +}: McInputButtonProps) { + const isAttached = variant === 'attached'; + + return ( +