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/container.py b/app/container.py index 0024ea6..726c376 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 @@ -86,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, @@ -97,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( 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", diff --git a/app/deps/cookie_auth.py b/app/deps/cookie_auth.py index 456fe68..4fe9b1e 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: @@ -17,27 +14,28 @@ 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") - 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 + raise AppException.unauthorized("Authentication token required") + + # 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_cached( + redis=container.redis, + token=token, + ttl_seconds=60, ) - if staff_user is None: - raise AppException.not_found("Staff user not found") + 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 + return staff_user def ensure_multi_team_lead_staff(current_staff_user: StaffUser) -> StaffUser: diff --git a/app/deps/token_auth.py b/app/deps/token_auth.py index 12c6718..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,41 +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") - - 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, - ) + _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, @@ -88,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, @@ -111,6 +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, ) return MobileUserSchema( @@ -124,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/mobile/auth.py b/app/router/mobile/auth.py index 81efcdb..5e64b30 100644 --- a/app/router/mobile/auth.py +++ b/app/router/mobile/auth.py @@ -13,6 +13,7 @@ from app.deps.rate_limit import RateLimiter from app.schema.request.mobile.auth import ( + McdiExchangeRequest, MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, @@ -118,6 +119,34 @@ async def mobile_login( return result +@router.post( + "/mcdi/exchange", + response_model=MobileAuthResponse, + dependencies=[Depends(RateLimiter(requests=5, window=60))], +) +async def mcdi_exchange( + req: McdiExchangeRequest, + request: Request, + container: Container = Depends(get_container), +) -> MobileAuthResponse: + client_ip = get_client_ip(request) + result = await container.auth_service.mcdi_exchange( + container.redis, req, client_ip=client_ip + ) + if result.is_new_user: + await container.audit_service.create_record( + event_type=AuditEventType.USER_SIGNUP, + user_id=result.user_id, + 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( "/refresh", response_model=MobileAuthResponse, diff --git a/app/router/web/auth.py b/app/router/web/auth.py index 76f945b..00f0c12 100644 --- a/app/router/web/auth.py +++ b/app/router/web/auth.py @@ -1,42 +1,81 @@ -from fastapi import APIRouter, Depends -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.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 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, - r: Response, +@router.get("/mcdi/login") +async def mcdi_login( container: Container = Depends(get_container), -) -> WebAuthResponse: +) -> RedirectResponse: + state = secrets.token_urlsafe(16) + 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", + ) + return response - authResponse = await container.staff_user_service.admin_login( - email=req.email, password=req.password + +@router.get("/mcdi/callback") +async def mcdi_callback( + request: Request, + code: str, + state: str, + container: Container = Depends(get_container), +) -> RedirectResponse: + cookie_state = request.cookies.get("mcdi_state") + if not cookie_state or state != cookie_state: + raise AppException.unauthorized("Invalid state parameter") + + mcdi_data = await container.mcdi_service.exchange_code( + code=code, + redirect_uri=settings.MCDI_REDIRECT_URI_WEB, ) - r.set_cookie( + + # 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") + + 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 +94,15 @@ 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: + if token: + await container.mcdi_service.logout(token, redis=container.redis) + r.delete_cookie( key="access_token", httponly=True, - secure=True, - samesite="strict", + secure=settings.environment != "dev", + samesite="lax", ) diff --git a/app/schema/request/mobile/auth.py b/app/schema/request/mobile/auth.py index 592b947..5685770 100644 --- a/app/schema/request/mobile/auth.py +++ b/app/schema/request/mobile/auth.py @@ -44,6 +44,31 @@ def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: return stripped +class McdiExchangeRequest(BaseModel): + code: str + device_name: str = Field( + ..., + 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 + + @field_validator("device_name", "device_type", mode="before") + @classmethod + def _strip_required_text(cls, value: object, info: ValidationInfo) -> object: + if not isinstance(value, str): + return value + 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 MobileRegisterRequest(MobileAuthBaseRequest): @field_validator("password") @classmethod @@ -81,8 +106,6 @@ 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 new file mode 100644 index 0000000..e15cc6a --- /dev/null +++ b/app/service/mcdi.py @@ -0,0 +1,149 @@ +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: + 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 callback code for a long-lived MCDI session token.""" + 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(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + 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 directly from MCDI.""" + 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(timeout=self._timeout) as client: + try: + response = await client.post(url, json=payload, headers=headers) + if response.status_code != 200: + 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/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/staff_user.py b/app/service/staff_user.py index b72cd89..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 @@ -101,28 +100,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( diff --git a/app/service/users.py b/app/service/users.py index d534ff5..20963f5 100644 --- a/app/service/users.py +++ b/app/service/users.py @@ -1,11 +1,12 @@ from datetime import datetime, timedelta, timezone import uuid from collections.abc import AsyncIterable -from typing import Optional +from typing import Optional, Any from fastapi import HTTPException -from sqlalchemy.exc import SQLAlchemyError +import json +import secrets from app.core.exceptions import AppException, DBException from app.core.securite import ( hash_password, @@ -18,17 +19,17 @@ ) 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 ( - MobileAuthBaseRequest, + McdiExchangeRequest, MobileLoginRequest, MobileRegisterRequest, RegisterVerifyRequest, ) from app.schema.response.mobile.auth import MobileAuthResponse, RegisterPendingResponse -from app.infra.nats import NatsClient -import secrets -import json +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 @@ -56,17 +57,19 @@ def __init__( session_querier: session_queries.AsyncQuerier, refresh_token_querier: refresh_token_queries.AsyncQuerier, face_embedding_service: FaceEmbeddingService, + 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 or McdiService() 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 @@ -316,13 +319,74 @@ async def verify_mobile_register( is_new_user=True, ) + async def mcdi_exchange( + self, + redis: RedisClient, + req: McdiExchangeRequest, + client_ip: Optional[str] = None, + ) -> MobileAuthResponse: + 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) + + mcdi_data = await self.mcdi_service.exchange_code(req.code, settings.MCDI_REDIRECT_URI_MOBILE) + + 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: + raise AppException.unauthorized("Invalid MCDI payload") + + await self.check_rate_limit(redis, f"rate:email:{email}", max_attempts, window) + + user = await self.user_querier.get_user_by_discord_id(discord_id=discord_id) + is_new_user = False + + if not user: + user = await self.user_querier.get_user_by_email(email=email) + if user: + 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 + ) + if not user: + raise AppException.internal_error("Failed to create user") + is_new_user = True + + if user.blocked: + raise AppException.forbidden("User is blocked") + + 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") + + return await self._create_mobile_session( + redis=redis, + user=locked_user, + req=req, + is_new_user=is_new_user, + mcdi_token=mcdi_token, + ) + async def _create_mobile_session( self, *, 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 +440,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( @@ -508,6 +573,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) ) @@ -582,6 +651,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") 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..7fb2167 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,16 @@ 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 +""" + + +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 """ @@ -136,8 +152,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] = 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 return models.User( @@ -151,6 +167,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 +182,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 +215,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 +233,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 +251,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 +268,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 +294,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 +312,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 +335,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 +353,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 +371,23 @@ 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], + ) + + 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 61c7c59..c6c3a17 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,18 @@ SELECT * FROM users WHERE email = $1; +-- name: GetUserByDiscordId :one +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/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/@/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 ( +