From 92c06d9335072cb700dd9e130518c7f2ab34a673 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Tue, 4 Aug 2026 20:40:42 +0500 Subject: [PATCH] login confirmation and forget password done --- .cursor/rules/backend-house-style.mdc | 122 ++ .gitignore | 8 + backend/.env.example | 24 + backend/forget_password/app.py | 68 + backend/forget_password/models.py | 106 ++ backend/forget_password/permissions.py | 11 + backend/forget_password/plugins.py | 77 + backend/forget_password/serializers.py | 27 + backend/forget_password/views.py | 109 ++ backend/main.py | 6 +- backend/notifications/app.py | 43 + backend/notifications/models.py | 102 ++ backend/notifications/plugins.py | 115 ++ backend/notifications/serializers.py | 20 + backend/notifications/views.py | 94 + backend/requirements.txt | 2 +- backend/users/app.py | 19 + backend/users/models.py | 2 +- backend/users/permissions.py | 1 + backend/users/plugins.py | 11 + backend/users/views.py | 24 +- devserver.py | 14 + frontend/.env.development | 1 + frontend/.env.production | 1 + frontend/index.html | 17 + frontend/package-lock.json | 1897 +++++++++++++++++++++ frontend/package.json | 20 + frontend/src/App.jsx | 22 + frontend/src/api.js | 125 ++ frontend/src/auth.css | 367 ++++ frontend/src/auth.js | 30 + frontend/src/components/Alert.jsx | 9 + frontend/src/components/AuthLayout.jsx | 67 + frontend/src/components/BrandMark.jsx | 13 + frontend/src/components/Countdown.jsx | 74 + frontend/src/components/OtpInput.jsx | 92 + frontend/src/components/PasswordField.jsx | 56 + frontend/src/components/Spinner.jsx | 7 + frontend/src/components/ThemeToggle.jsx | 46 + frontend/src/main.jsx | 14 + frontend/src/pages/ConfirmEmail.jsx | 142 ++ frontend/src/pages/ForgotPassword.jsx | 286 ++++ frontend/src/pages/Login.jsx | 93 + frontend/src/pages/Signup.jsx | 211 +++ frontend/src/theme.js | 32 + frontend/vite.config.js | 15 + index.html | 2 +- js/app.js | 39 + 48 files changed, 4677 insertions(+), 6 deletions(-) create mode 100644 .cursor/rules/backend-house-style.mdc create mode 100644 backend/.env.example create mode 100644 backend/forget_password/app.py create mode 100644 backend/forget_password/models.py create mode 100644 backend/forget_password/permissions.py create mode 100644 backend/forget_password/plugins.py create mode 100644 backend/forget_password/serializers.py create mode 100644 backend/forget_password/views.py create mode 100644 backend/notifications/app.py create mode 100644 backend/notifications/models.py create mode 100644 backend/notifications/plugins.py create mode 100644 backend/notifications/serializers.py create mode 100644 backend/notifications/views.py create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.jsx create mode 100644 frontend/src/api.js create mode 100644 frontend/src/auth.css create mode 100644 frontend/src/auth.js create mode 100644 frontend/src/components/Alert.jsx create mode 100644 frontend/src/components/AuthLayout.jsx create mode 100644 frontend/src/components/BrandMark.jsx create mode 100644 frontend/src/components/Countdown.jsx create mode 100644 frontend/src/components/OtpInput.jsx create mode 100644 frontend/src/components/PasswordField.jsx create mode 100644 frontend/src/components/Spinner.jsx create mode 100644 frontend/src/components/ThemeToggle.jsx create mode 100644 frontend/src/main.jsx create mode 100644 frontend/src/pages/ConfirmEmail.jsx create mode 100644 frontend/src/pages/ForgotPassword.jsx create mode 100644 frontend/src/pages/Login.jsx create mode 100644 frontend/src/pages/Signup.jsx create mode 100644 frontend/src/theme.js create mode 100644 frontend/vite.config.js diff --git a/.cursor/rules/backend-house-style.mdc b/.cursor/rules/backend-house-style.mdc new file mode 100644 index 0000000..99bf390 --- /dev/null +++ b/.cursor/rules/backend-house-style.mdc @@ -0,0 +1,122 @@ +--- +description: Strict HR-ATS backend house style — layering, routes, serializers, auth +globs: backend/**/*.py +alwaysApply: false +--- + +# HR-ATS Backend — singular pattern (mandatory) + +Match existing modules (`users/`, `inbox/`) exactly. Do not introduce alternate frameworks, layers, or response shapes. + +## Package layout (every domain) + +``` +backend// + app.py # routes only — HTTP in/out + views.py # service class — business logic + models.py # SQLModel table + classmethod DB accessors + serializers.py # hand-rolled dict builders (no Pydantic response models) + plugins.py # pure helpers (hash, JWT, clean payload) — NO FastAPI imports + permissions.py # OAuth2 scheme + Depends aliases (auth domains only) +``` + +- Mount with bare `router = APIRouter()`; register in `main.py` via `app.include_router(...)`. +- No package `__init__.py`; run uvicorn from `backend/` so imports are top-level (`users.app`, `db_setup`). +- Config: `load_dotenv()` + `os.getenv(...)` in the module that needs it. Do not extend `db_setup.Settings` for non-DB keys. + +## Layer duties (strict) + +| Layer | Owns | Must NOT | +|---|---|---| +| `app.py` | Routes, request Pydantic models (inline), `JSONResponse`, call serializers for HTTP payloads, inject `CurrentUser` / `session` | Business rules, DB queries, JWT encode logic beyond calling plugins | +| `views.py` | Validate rules, call models, raise `HTTPException`, return ORM rows or serialized dicts for CRUD | Build login/token HTTP envelopes; call `serialize_token` | +| `models.py` | Table fields, `select`/`insert`/`update`/`soft_delete`, `selectinload` when relations are needed | HTTPExceptions, serializers, FastAPI | +| `serializers.py` | `serialize_*` → plain `dict` (`str(uuid)`, `.isoformat()` dates, never password) | DB access, Depends | +| `plugins.py` | Pure functions; raise library errors (e.g. `jwt.*`), not HTTP | Import FastAPI | +| `permissions.py` | `OAuth2PasswordBearer`, `get_current_user`, `CurrentUser = Annotated[...]`, `require_permission` | Route handlers | + +## Route pattern (`app.py`) + +- Verb-in-path: `/users/create`, `/users/fetch`, `/users/login` — not `/auth/token`, not REST nouns-only. +- Every handler uses this wrapper: + +```python +try: + service=User(session=session) + data=await service.some_method(...) + return JSONResponse(content={"data":data,"status_code":200}) +except HTTPException: + raise +except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) +``` + +- List fetch: `{"data":items,"total":total,"status_code":200}`; single-by-id: `total: 1`. +- Login/refresh: build tokens in the route, then serialize: + +```python +user=await service.authenticate_user(...) +tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) +return JSONResponse(content={**tokens,"status_code":200}) +``` + +- Request schemas live **inline** in `app.py` (`UserCreate`, `TokenRefresh`, …). Not in `serializers.py`. +- Dependencies: default form `session: AsyncSession = Depends(get_session)` unless `Annotated` is required (auth form / `CurrentUser` before other defaults). +- Tight spacing house style: `service=User(session=session)`, `detail=str(e)`, `key=value` in calls — match neighbors, do not “pretty-reformat” whole files. + +## Service pattern (`views.py`) + +```python +class User: + def __init__(self,session:AsyncSession): + self.session=session + + async def create_user(self,payload): + ... + return serialize_user(user) # CRUD returns serialized dict +``` + +- Untyped method args (match existing). Raise `HTTPException(status_code=...,detail="...")`. +- Auth methods (`authenticate_user`, `refresh_access_token`) return the **ORM user** only — never `serialize_token`. +- After email lookup that lacks `selectinload(role)`, re-fetch via `get_user_by_id` before anything that touches `user.role`. + +## Model accessors (`models.py`) + +- `@classmethod async def get_* / insert_* / update_* / soft_delete_* / count_*`. +- Soft delete: set `is_deleted=True`, `is_active=False`. +- Use `selectinload(cls.role)` on fetches that will be serialized with role fields. +- Commit inside model write methods (existing pattern). + +## Auth pattern + +- Access + refresh JWTs via `plugins` (`type` claim must be checked in `decode_token`). +- `permissions.CurrentUser` on every protected `/users/*` route; login + refresh stay open. +- `get_current_user`: decode access → DB load by `sub` → reject missing/deleted/inactive → `serialize_user`. +- Login uses `OAuth2PasswordRequestForm`; username field carries email. +- OAuth2 fields (`access_token`, `refresh_token`, `token_type`, `expires_in`) at **response root**; user under `data`. +- `tokenUrl="users/login"` (no leading slash). +- No FastAPI in `plugins.py`. Translate `jwt.PyJWTError` → 401 in `permissions` / `views`. + +## Dependencies / env + +- Pin in `requirements.txt` under comment banners with trailing rationale comments. +- Secrets in `backend/.env`; document keys in `backend/.env.example`. +- JWT times: `datetime.now(timezone.utc)` only (never naive `datetime.now()` for token `iat`/`exp`). + +## Hard bans + +- No new abstraction layers (repositories, use-cases, DTOs beyond inline Pydantic requests). +- No Pydantic response models; no `jsonable_encoder` for these routes. +- No changing response envelope (`data` + `status_code`) or inventing `/api/v1` prefixes. +- No drive-by refactors or reformatting unrelated code. +- No RBAC second scheme — vocabulary and `require_permission` live in `users/permissions.py` only. +- Do not touch `inbox/` when the task is `users/` (and vice versa) unless asked. +- No `__init__.py` packages; no moving request models into `serializers.py`. + +## When adding a new domain endpoint + +1. Accessor on `models.py` if DB changes. +2. Method on service in `views.py`. +3. `serialize_*` in `serializers.py` if new shape. +4. Route in `app.py` with the standard try/except + `JSONResponse`. +5. Protect with `current_user: CurrentUser` if under an authenticated router. diff --git a/.gitignore b/.gitignore index 368e0dd..f76628d 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,8 @@ env/ .env .env.* !.env.example +!frontend/.env.development +!frontend/.env.production # Logs & temp *.log @@ -43,4 +45,10 @@ tmp/ temp/ .cache/ +# Frontend / auth build +node_modules/ +frontend/dist/ +/auth/ + **.pdf +**_**_**.py \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..aee1e77 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,24 @@ +DB_USERNAME= +DB_PASSWORD= +DB_HOST= +DB_PORT= +DB_NAME= +EMAIL_URL= + +JWT_SECRET_KEY= +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30 +JWT_REFRESH_TOKEN_EXPIRE_DAYS=7 +JWT_RESET_TOKEN_EXPIRE_MINUTES=10 + +TEAMS_MAIL_API_URL= +TEAMS_API_TOKEN= + +RESET_CODE_TTL_SECONDS=60 +RESET_CODE_RESEND_SECONDS=30 +RESET_CODE_MAX_ATTEMPTS=5 + +FRONTEND_URL=http://localhost:5173 +CONFIRM_EMAIL_PATH=/auth/confirm-email +CONFIRM_TOKEN_TTL_SECONDS=86400 +CONFIRM_TOKEN_RESEND_SECONDS=60 diff --git a/backend/forget_password/app.py b/backend/forget_password/app.py new file mode 100644 index 0000000..38c408d --- /dev/null +++ b/backend/forget_password/app.py @@ -0,0 +1,68 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from forget_password.views import ForgetPassword +from forget_password.permissions import ResetCredentials +from forget_password.serializers import serialize_reset_token +from users.plugins import create_reset_token +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class ForgetPasswordRequest(BaseModel): + email: EmailStr + + +class ForgetPasswordVerify(BaseModel): + email: EmailStr + code: str + + +class ForgetPasswordNew(BaseModel): + password: str + + +@router.post("/users/forget-password") +async def request_reset_code(payload: ForgetPasswordRequest,session: AsyncSession = Depends(get_session)): + try: + service=ForgetPassword(session=session) + data=await service.request_code(payload.email) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/forget-password/verify-code") +async def verify_reset_code(payload: ForgetPasswordVerify,session: AsyncSession = Depends(get_session)): + try: + service=ForgetPassword(session=session) + email,code_id=await service.verify_code(payload.email,payload.code) + tokens=serialize_reset_token(create_reset_token(email,code_id=code_id),email) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/forget-password/new-password") +async def set_new_password( + payload: ForgetPasswordNew, + credentials: ResetCredentials, + session: AsyncSession = Depends(get_session), +): + try: + service=ForgetPassword(session=session) + data=await service.set_new_password(credentials.credentials,payload.password) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/forget_password/models.py b/backend/forget_password/models.py new file mode 100644 index 0000000..eb04e29 --- /dev/null +++ b/backend/forget_password/models.py @@ -0,0 +1,106 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class PasswordResetCodes(SQLModel, table=True): + __tablename__ = "password_reset_codes" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + email: str = Field(index=True) + code_hash: str + expires_at: datetime = Field(sa_type=DateTime(timezone=True)) + attempts: int = Field(default=0) + is_used: bool = Field(default=False) + verified_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_code_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_active_code_by_email(cls, session: AsyncSession, email: str): + """Newest unused code for email (expiry checked in Python by the service).""" + statement = ( + select(cls) + .where(cls.email == email, cls.is_used == False) # noqa: E712 + .order_by(cls.created_at.desc()) + ) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def insert_code(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_code_by_id(session, row.id) + + @classmethod + async def increment_attempts(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.attempts = (row.attempts or 0) + 1 + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_verified(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.verified_at = _now() + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_used(cls, session: AsyncSession, record_id: str): + row = await cls.get_code_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def invalidate_codes_for_email(cls, session: AsyncSession, email: str): + statement = select(cls).where(cls.email == email, cls.is_used == False) # noqa: E712 + result = await session.execute(statement) + rows = result.scalars().all() + now = _now() + for row in rows: + row.is_used = True + row.updated_at = now + session.add(row) + await session.commit() + return len(rows) diff --git a/backend/forget_password/permissions.py b/backend/forget_password/permissions.py new file mode 100644 index 0000000..39719f4 --- /dev/null +++ b/backend/forget_password/permissions.py @@ -0,0 +1,11 @@ +"""Bearer scheme for password-reset tokens (type=reset JWT).""" + +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +reset_scheme = HTTPBearer() +ResetCredentials = Annotated[HTTPAuthorizationCredentials, Depends(reset_scheme)] diff --git a/backend/forget_password/plugins.py b/backend/forget_password/plugins.py new file mode 100644 index 0000000..fb4b67e --- /dev/null +++ b/backend/forget_password/plugins.py @@ -0,0 +1,77 @@ +"""Forget-password helpers — OTP generation, hashing, and Teams mail send. + +Pure module: no FastAPI imports and no HTTPException. +""" + +from __future__ import annotations + +import os +import secrets +from datetime import datetime, timedelta, timezone + +import httpx +from dotenv import load_dotenv + +from users.plugins import hash_password, verify_password + +load_dotenv() + +TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL") +TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN") +RESET_CODE_TTL_SECONDS = int(os.getenv("RESET_CODE_TTL_SECONDS", "60")) +RESET_CODE_RESEND_SECONDS = int(os.getenv("RESET_CODE_RESEND_SECONDS", "30")) +RESET_CODE_MAX_ATTEMPTS = int(os.getenv("RESET_CODE_MAX_ATTEMPTS", "5")) +MAIL_ACCEPTED_STATUS = 202 + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def code_expiry(*, now: datetime | None = None) -> datetime: + return (now or now_utc()) + timedelta(seconds=RESET_CODE_TTL_SECONDS) + + +def generate_code() -> str: + return f"{secrets.randbelow(1_000_000):06d}" + + +def hash_code(code: str) -> str: + return hash_password(code) + + +def verify_code(code: str, code_hash: str) -> bool: + return verify_password(code, code_hash) + + +def render_reset_email(code: str, ttl: int) -> tuple[str, str]: + subject = "Your TalentFlow password reset code" + html = ( + f"

Your password reset code is {code}.

" + f"

It expires in {ttl} seconds. If you did not request this, ignore this email.

" + ) + return subject, html + + +async def send_reset_mail(to_email: str, subject: str, html: str) -> None: + if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN: + raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set") + fields = [ + ("subject", (None, subject)), + ("body", (None, html)), + ("content_type", (None, "html")), + ("save_to_sent_items", (None, "false")), + ("to", (None, to_email)), + ] + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TEAMS_MAIL_API_URL, + files=fields, + headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"}, + ) + if response.status_code != MAIL_ACCEPTED_STATUS: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) diff --git a/backend/forget_password/serializers.py b/backend/forget_password/serializers.py new file mode 100644 index 0000000..a96a7dd --- /dev/null +++ b/backend/forget_password/serializers.py @@ -0,0 +1,27 @@ +from forget_password.plugins import RESET_CODE_RESEND_SECONDS,RESET_CODE_TTL_SECONDS +from users.plugins import RESET_TOKEN_EXPIRE_SECONDS + + +def serialize_reset_request(email: str,expires_at) -> dict: + return { + "email": email, + "expires_at": expires_at.isoformat() if expires_at else None, + "expires_in": RESET_CODE_TTL_SECONDS, + "resend_after": RESET_CODE_RESEND_SECONDS, + } + + +def serialize_reset_token(reset_token: str,email: str) -> dict: + return { + "reset_token": reset_token, + "token_type": "bearer", + "expires_in": RESET_TOKEN_EXPIRE_SECONDS, + "data": {"email": email}, + } + + +def serialize_reset_result(email: str) -> dict: + return { + "email": email, + "password_updated": True, + } diff --git a/backend/forget_password/views.py b/backend/forget_password/views.py new file mode 100644 index 0000000..cb9fc7b --- /dev/null +++ b/backend/forget_password/views.py @@ -0,0 +1,109 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import httpx +import jwt + +from forget_password.models import PasswordResetCodes +from forget_password.plugins import ( + RESET_CODE_MAX_ATTEMPTS, + RESET_CODE_RESEND_SECONDS, + code_expiry, + generate_code, + hash_code, + now_utc, + render_reset_email, + send_reset_mail, + verify_code, + RESET_CODE_TTL_SECONDS, +) +from forget_password.serializers import serialize_reset_request,serialize_reset_result +from users.models import Users +from users.plugins import decode_token,hash_password + + +class ForgetPassword: + def __init__(self,session:AsyncSession): + self.session=session + + async def request_code(self,email): + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted or not user.is_active: + raise HTTPException(status_code=404,detail="No account found for this email") + + active=await PasswordResetCodes.get_active_code_by_email(self.session,email) + if active: + age=(now_utc()-active.created_at).total_seconds() + if agenow_utc(): + raise HTTPException(status_code=429,detail="Please wait before requesting another code") + + await PasswordResetCodes.invalidate_codes_for_email(self.session,email) + + code=generate_code() + expires_at=code_expiry() + row=await PasswordResetCodes.insert_code(self.session,{ + "email":email, + "code_hash":hash_code(code), + "expires_at":expires_at, + }) + + subject,html=render_reset_email(code,RESET_CODE_TTL_SECONDS) + try: + await send_reset_mail(email,subject,html) + except (httpx.HTTPError,RuntimeError) as e: + await PasswordResetCodes.mark_used(self.session,str(row.id)) + raise HTTPException(status_code=502,detail="Failed to send reset email") from e + + return serialize_reset_request(email,expires_at) + + async def verify_code(self,email,code): + row=await PasswordResetCodes.get_active_code_by_email(self.session,email) + if not row: + raise HTTPException(status_code=400,detail="No active reset code for this email") + if row.expires_at<=now_utc(): + raise HTTPException(status_code=400,detail="Reset code has expired") + if (row.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: + raise HTTPException(status_code=429,detail="Too many invalid attempts") + + if not verify_code(code,row.code_hash): + updated=await PasswordResetCodes.increment_attempts(self.session,str(row.id)) + if updated and (updated.attempts or 0)>=RESET_CODE_MAX_ATTEMPTS: + raise HTTPException(status_code=429,detail="Too many invalid attempts") + raise HTTPException(status_code=400,detail="Invalid reset code") + + await PasswordResetCodes.mark_verified(self.session,str(row.id)) + return email,str(row.id) + + async def set_new_password(self,reset_token,password): + try: + payload=decode_token(reset_token,expected_type="reset") + except jwt.PyJWTError: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + email=payload.get("sub") + code_id=payload.get("crid") + if not email or not code_id: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + row=await PasswordResetCodes.get_code_by_id(self.session,code_id) + if not row or row.is_used or row.email!=email or not row.verified_at: + raise HTTPException( + status_code=401, + detail="Invalid or expired reset token", + headers={"WWW-Authenticate":"Bearer"}, + ) + + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="User not found") + + await Users.update_user(self.session,str(user.id),{"password":hash_password(password)}) + await PasswordResetCodes.mark_used(self.session,str(row.id)) + return serialize_reset_result(email) diff --git a/backend/main.py b/backend/main.py index c13d9f4..2f3905b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -8,6 +8,8 @@ from db_setup import lifespan from inbox.app import router as inbox_router from users.app import router as users_router from role.app import router as role_router +from forget_password.app import router as forget_password_router +from notifications.app import router as confirmation_router # Without this the db/migration logs have no handler and are swallowed under uvicorn. logging.basicConfig(level=logging.INFO, format="%(levelname)-8s %(name)s: %(message)s") @@ -24,4 +26,6 @@ app.add_middleware( app.include_router(inbox_router) app.include_router(users_router) -app.include_router(role_router) \ No newline at end of file +app.include_router(role_router) +app.include_router(forget_password_router) +app.include_router(confirmation_router) \ No newline at end of file diff --git a/backend/notifications/app.py b/backend/notifications/app.py new file mode 100644 index 0000000..f26159c --- /dev/null +++ b/backend/notifications/app.py @@ -0,0 +1,43 @@ +from fastapi import APIRouter,Depends +from fastapi.responses import JSONResponse +from fastapi import HTTPException +from db_setup import get_session +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, EmailStr +from notifications.views import Confirmation +from dotenv import load_dotenv +load_dotenv() + +router = APIRouter() + + +class ConfirmEmailRequest(BaseModel): + token: str + + +class ConfirmEmailResend(BaseModel): + email: EmailStr + + +@router.post("/users/confirm-email") +async def confirm_email(payload: ConfirmEmailRequest,session: AsyncSession = Depends(get_session)): + try: + service=Confirmation(session=session) + data=await service.confirm(payload.token) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.post("/users/confirm-email/resend") +async def resend_confirm_email(payload: ConfirmEmailResend,session: AsyncSession = Depends(get_session)): + try: + service=Confirmation(session=session) + data=await service.resend(payload.email) + return JSONResponse(content={"data":data,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) diff --git a/backend/notifications/models.py b/backend/notifications/models.py new file mode 100644 index 0000000..ceca0c3 --- /dev/null +++ b/backend/notifications/models.py @@ -0,0 +1,102 @@ +import uuid +from datetime import datetime, timezone + +from sqlalchemy import DateTime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlmodel import Field, SQLModel, select + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class EmailConfirmationTokens(SQLModel, table=True): + __tablename__ = "email_confirmation_tokens" + + id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) + user_id: uuid.UUID = Field(index=True, foreign_key="users.id") + email: str = Field(index=True) + token_hash: str + expires_at: datetime = Field(sa_type=DateTime(timezone=True)) + is_used: bool = Field(default=False) + confirmed_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) + created_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + updated_at: datetime = Field(default_factory=_now, sa_type=DateTime(timezone=True)) + + @staticmethod + def _as_uuid(record_id: str) -> uuid.UUID | None: + try: + return uuid.UUID(str(record_id)) + except ValueError: + return None + + @classmethod + async def get_token_by_id(cls, session: AsyncSession, record_id: str): + uid = cls._as_uuid(record_id) + if uid is None: + return None + result = await session.execute(select(cls).where(cls.id == uid)) + return result.scalars().first() + + @classmethod + async def get_active_token_by_user(cls, session: AsyncSession, user_id: str): + """Newest unused token for a user (expiry checked in Python by the service).""" + uid = cls._as_uuid(user_id) + if uid is None: + return None + statement = ( + select(cls) + .where(cls.user_id == uid, cls.is_used == False) # noqa: E712 + .order_by(cls.created_at.desc()) + ) + result = await session.execute(statement) + return result.scalars().first() + + @classmethod + async def insert_token(cls, session: AsyncSession, fields: dict): + row = cls(**fields) + session.add(row) + await session.commit() + return await cls.get_token_by_id(session, row.id) + + @classmethod + async def mark_confirmed(cls, session: AsyncSession, record_id: str): + row = await cls.get_token_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.confirmed_at = _now() + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def mark_used(cls, session: AsyncSession, record_id: str): + """Retire a token without confirming it — used when the mail send fails.""" + row = await cls.get_token_by_id(session, record_id) + if not row: + return None + row.is_used = True + row.updated_at = _now() + session.add(row) + await session.commit() + await session.refresh(row) + return row + + @classmethod + async def invalidate_tokens_for_user(cls, session: AsyncSession, user_id: str): + uid = cls._as_uuid(user_id) + if uid is None: + return 0 + statement = select(cls).where(cls.user_id == uid, cls.is_used == False) # noqa: E712 + result = await session.execute(statement) + rows = result.scalars().all() + now = _now() + for row in rows: + row.is_used = True + row.updated_at = now + session.add(row) + await session.commit() + return len(rows) diff --git a/backend/notifications/plugins.py b/backend/notifications/plugins.py new file mode 100644 index 0000000..69ce5bb --- /dev/null +++ b/backend/notifications/plugins.py @@ -0,0 +1,115 @@ +"""Confirmation helpers — token generation, hashing, link building, and Teams mail send. + +Pure module: no FastAPI imports and no HTTPException. + +`send_confirmation_mail` intentionally duplicates +`forget_password.plugins.send_reset_mail` rather than importing it: that helper is +domain-named, and each domain owns its own mail copy and its own env reads. +""" + +from __future__ import annotations + +import os +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from urllib.parse import quote + +import httpx +from dotenv import load_dotenv + +from users.plugins import hash_password, verify_password + +load_dotenv() + +TEAMS_MAIL_API_URL = os.getenv("TEAMS_MAIL_API_URL") +TEAMS_API_TOKEN = os.getenv("TEAMS_API_TOKEN") +FRONTEND_URL = os.getenv("FRONTEND_URL", "http://localhost:5173") +CONFIRM_EMAIL_PATH = os.getenv("CONFIRM_EMAIL_PATH", "/auth/confirm-email") +CONFIRM_TOKEN_TTL_SECONDS = int(os.getenv("CONFIRM_TOKEN_TTL_SECONDS", "86400")) +CONFIRM_TOKEN_RESEND_SECONDS = int(os.getenv("CONFIRM_TOKEN_RESEND_SECONDS", "60")) +MAIL_ACCEPTED_STATUS = 202 + +# 32 bytes -> 43 url-safe characters, well under users.plugins.BCRYPT_MAX_BYTES. +TOKEN_SECRET_BYTES = 32 + + +def now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def confirmation_expiry(*, now: datetime | None = None) -> datetime: + return (now or now_utc()) + timedelta(seconds=CONFIRM_TOKEN_TTL_SECONDS) + + +def generate_token_secret() -> str: + return secrets.token_urlsafe(TOKEN_SECRET_BYTES) + + +def hash_token(secret: str) -> str: + return hash_password(secret) + + +def verify_token(secret: str, token_hash: str) -> bool: + return verify_password(secret, token_hash) + + +def compose_token(record_id, secret: str) -> str: + """Link token. A bcrypt hash cannot be looked up, so the row id rides along.""" + return f"{record_id}.{secret}" + + +def split_token(token: str) -> tuple[str | None, str | None]: + """('','') or (None,None). token_urlsafe never emits a dot.""" + if not token or "." not in token: + return None, None + record_id, secret = token.split(".", 1) + if not record_id or not secret: + return None, None + try: + uuid.UUID(record_id) + except ValueError: + return None, None + return record_id, secret + + +def build_confirmation_link(token: str) -> str: + base = FRONTEND_URL.rstrip("/") + path = CONFIRM_EMAIL_PATH if CONFIRM_EMAIL_PATH.startswith("/") else f"/{CONFIRM_EMAIL_PATH}" + return f"{base}{path}?token={quote(token, safe='')}" + + +def render_confirmation_email(link: str, ttl: int) -> tuple[str, str]: + hours = max(1, ttl // 3600) + subject = "Confirm your TalentFlow account" + html = ( + "

Welcome to TalentFlow. Confirm your email address to activate your account.

" + f'

Confirm my email

' + f"

This link expires in {hours} hour(s). If you did not sign up, ignore this email.

" + f"

If the link does not open, paste this into your browser:
{link}

" + ) + return subject, html + + +async def send_confirmation_mail(to_email: str, subject: str, html: str) -> None: + if not TEAMS_MAIL_API_URL or not TEAMS_API_TOKEN: + raise RuntimeError("TEAMS_MAIL_API_URL and TEAMS_API_TOKEN must be set") + fields = [ + ("subject", (None, subject)), + ("body", (None, html)), + ("content_type", (None, "html")), + ("save_to_sent_items", (None, "false")), + ("to", (None, to_email)), + ] + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post( + TEAMS_MAIL_API_URL, + files=fields, + headers={"Authorization": f"Bearer {TEAMS_API_TOKEN}"}, + ) + if response.status_code != MAIL_ACCEPTED_STATUS: + raise httpx.HTTPStatusError( + response.text, + request=response.request, + response=response, + ) diff --git a/backend/notifications/serializers.py b/backend/notifications/serializers.py new file mode 100644 index 0000000..62f4ee0 --- /dev/null +++ b/backend/notifications/serializers.py @@ -0,0 +1,20 @@ +from notifications.plugins import CONFIRM_TOKEN_RESEND_SECONDS,CONFIRM_TOKEN_TTL_SECONDS + + +def serialize_confirmation_request(email: str,expires_at) -> dict: + return { + "email": email, + "confirmation_sent": True, + "expires_at": expires_at.isoformat() if expires_at else None, + "expires_in": CONFIRM_TOKEN_TTL_SECONDS, + "resend_after": CONFIRM_TOKEN_RESEND_SECONDS, + } + + +def serialize_confirmation_result(user,*,already_confirmed: bool = False) -> dict: + """Deliberately narrow: this is an unauthenticated response, so no role or permissions.""" + return { + "email": user.email, + "is_active": user.is_active, + "already_confirmed": already_confirmed, + } diff --git a/backend/notifications/views.py b/backend/notifications/views.py new file mode 100644 index 0000000..8e6521a --- /dev/null +++ b/backend/notifications/views.py @@ -0,0 +1,94 @@ +from fastapi import HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +import httpx + +from notifications.models import EmailConfirmationTokens +from notifications.plugins import ( + CONFIRM_TOKEN_RESEND_SECONDS, + CONFIRM_TOKEN_TTL_SECONDS, + build_confirmation_link, + compose_token, + confirmation_expiry, + generate_token_secret, + hash_token, + now_utc, + render_confirmation_email, + send_confirmation_mail, + split_token, + verify_token, +) +from notifications.serializers import serialize_confirmation_request,serialize_confirmation_result +from users.models import Users + + +class Confirmation: + def __init__(self,session:AsyncSession): + self.session=session + + async def send_confirmation(self,user): + """Issue a fresh token for an already committed Users row and mail the link.""" + await EmailConfirmationTokens.invalidate_tokens_for_user(self.session,str(user.id)) + + secret=generate_token_secret() + expires_at=confirmation_expiry() + row=await EmailConfirmationTokens.insert_token(self.session,{ + "user_id":user.id, + "email":user.email, + "token_hash":hash_token(secret), + "expires_at":expires_at, + }) + + link=build_confirmation_link(compose_token(row.id,secret)) + subject,html=render_confirmation_email(link,CONFIRM_TOKEN_TTL_SECONDS) + try: + await send_confirmation_mail(user.email,subject,html) + except (httpx.HTTPError,RuntimeError) as e: + await EmailConfirmationTokens.mark_used(self.session,str(row.id)) + raise HTTPException(status_code=502,detail="Failed to send confirmation email") from e + + return serialize_confirmation_request(user.email,expires_at) + + async def confirm(self,token): + record_id,secret=split_token(token) + if not record_id: + raise HTTPException(status_code=400,detail="Invalid confirmation link") + + row=await EmailConfirmationTokens.get_token_by_id(self.session,record_id) + if not row or not verify_token(secret,row.token_hash): + raise HTTPException(status_code=400,detail="Invalid confirmation link") + + user=await Users.get_user_by_id(self.session,str(row.user_id)) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="User not found") + + # Mail clients, link scanners and the back button all replay this link. + if row.is_used: + if row.confirmed_at and user.is_active: + return serialize_confirmation_result(user,already_confirmed=True) + raise HTTPException(status_code=400,detail="This confirmation link is no longer valid. Request a new one.") + + if row.expires_at<=now_utc(): + raise HTTPException(status_code=400,detail="Confirmation link has expired. Request a new one.") + + if user.is_active: + await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) + return serialize_confirmation_result(user,already_confirmed=True) + + updated=await Users.update_user(self.session,str(user.id),{"is_active":True}) + await EmailConfirmationTokens.mark_confirmed(self.session,str(row.id)) + return serialize_confirmation_result(updated) + + async def resend(self,email): + user=await Users.get_user_by_email(self.session,email) + if not user or user.is_deleted: + raise HTTPException(status_code=404,detail="No account found for this email") + if user.is_active: + raise HTTPException(status_code=400,detail="This account is already confirmed") + + active=await EmailConfirmationTokens.get_active_token_by_user(self.session,str(user.id)) + if active: + age=(now_utc()-active.created_at).total_seconds() + if agenow_utc(): + raise HTTPException(status_code=429,detail="Please wait before requesting another confirmation email") + + return await self.send_confirmation(user) diff --git a/backend/requirements.txt b/backend/requirements.txt index e3c9c13..bfb074d 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -24,5 +24,5 @@ PyJWT==2.10.1 # access/refresh token encode+decode in users/plugins python-multipart==0.0.20 # required by OAuth2PasswordRequestForm in users/app.py # --- other ----------------------------------------------------------------- -httpx==0.28.1 # Graph email calls in inbox/views.py +httpx==0.28.1 # Graph email (inbox) + Teams mail send (forget_password/plugins.py) bcrypt==5.0.0 # password hashing in users/plugins.py diff --git a/backend/users/app.py b/backend/users/app.py index e6bb2e9..843c4de 100644 --- a/backend/users/app.py +++ b/backend/users/app.py @@ -22,6 +22,12 @@ class UserCreate(BaseModel): is_active: bool = True +class UserSignup(BaseModel): + name: str + email: EmailStr + password: str + + class UserUpdate(BaseModel): name: str | None = None email: EmailStr | None = None @@ -62,6 +68,19 @@ async def login(payload: UserLogin,session: AsyncSession = Depends(get_session)) raise HTTPException(status_code=500,detail=str(e)) +@router.post("/users/signup") +async def signup(payload: UserSignup,session: AsyncSession = Depends(get_session)): + try: + service=User(session=session) + user=await service.signup_user(payload.model_dump()) + tokens=serialize_token(create_access_token(user),create_refresh_token(user),user) + return JSONResponse(content={**tokens,"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.post("/users/refresh") async def refresh(payload: TokenRefresh,session: AsyncSession = Depends(get_session)): try: diff --git a/backend/users/models.py b/backend/users/models.py index 6a52910..4171253 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -20,7 +20,7 @@ class Users(SQLModel, table=True): password: str created_at: datetime = Field(default_factory=datetime.now) updated_at: datetime = Field(default_factory=datetime.now) - is_active: bool = Field(default=True) + is_active: bool = Field(default=False) is_deleted: bool = Field(default=False) @classmethod diff --git a/backend/users/permissions.py b/backend/users/permissions.py index 7b1d7e0..a7c11b0 100644 --- a/backend/users/permissions.py +++ b/backend/users/permissions.py @@ -194,6 +194,7 @@ def require_permission(*required: PermissionTag, require_all: bool = True): async def dependency(current_user: CurrentUser) -> dict: if current_user.get("role_id") is None: raise HTTPException(status_code=403, detail="User has no role assigned") + granted = current_user.get("permissions") or [] if not has_permission(granted, *required, require_all=require_all): if require_all and len(required) == 1: diff --git a/backend/users/plugins.py b/backend/users/plugins.py index 74546df..68dc4d6 100644 --- a/backend/users/plugins.py +++ b/backend/users/plugins.py @@ -28,7 +28,9 @@ JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY") JWT_ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256") ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_ACCESS_TOKEN_EXPIRE_MINUTES", "30")) REFRESH_TOKEN_EXPIRE_DAYS = int(os.getenv("JWT_REFRESH_TOKEN_EXPIRE_DAYS", "7")) +RESET_TOKEN_EXPIRE_MINUTES = int(os.getenv("JWT_RESET_TOKEN_EXPIRE_MINUTES", "10")) ACCESS_TOKEN_EXPIRE_SECONDS = ACCESS_TOKEN_EXPIRE_MINUTES * 60 +RESET_TOKEN_EXPIRE_SECONDS = RESET_TOKEN_EXPIRE_MINUTES * 60 def _encode(raw: str) -> bytes: @@ -112,6 +114,15 @@ def create_refresh_token(user) -> str: ) +def create_reset_token(email: str, *, code_id: str) -> str: + return _create_token( + email, + token_type="reset", + expires_delta=timedelta(minutes=RESET_TOKEN_EXPIRE_MINUTES), + claims={"crid": code_id}, + ) + + def decode_token(token: str, *, expected_type: str) -> dict: payload = jwt.decode(token, _secret(), algorithms=[JWT_ALGORITHM]) if payload.get("type") != expected_type: diff --git a/backend/users/views.py b/backend/users/views.py index 577a7c5..bce5072 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,5 +1,6 @@ from fastapi import HTTPException -from role.models import Roles +from notifications.views import Confirmation +from role.models import EnumRoles,Roles from users.models import Users from users.permissions import PermissionTag,has_permission from users.serializers import serialize_user @@ -17,6 +18,8 @@ class User: async def _check_role_assignment(self,current_user,role_id,existing_role_id=None): if role_id==existing_role_id: return + + #"Take the current user's permission list. Check if rbac_users.manage is in it. If it is not → reject with 403." if not has_permission(current_user.get("permissions") or [],PermissionTag.RBAC_USERS_MANAGE): raise HTTPException(status_code=403,detail="Assigning a role requires rbac_users.manage") if role_id is None: @@ -42,6 +45,21 @@ class User: raise HTTPException(status_code=400,detail="Password is required") return await Users.insert_user(self.session,fields) + async def signup_user(self,payload): + existing=await Users.get_user_by_email(self.session,payload.get("email")) + if existing: + raise HTTPException(status_code=409,detail="Email already registered") + fields=clean_user_payload(payload) + if not fields.get("password"): + raise HTTPException(status_code=400,detail="Password is required") + role=await Roles.get_role_by_name(self.session,EnumRoles.CANDIDATE.value) + fields["role_id"]=role.id if role else 8 + user=await Users.insert_user(self.session,fields) + # Signup lands inactive; the mailed link is what flips is_active. + service=Confirmation(session=self.session) + await service.send_confirmation(user) + return user + async def get_users(self,top,skip,search=None): users=await Users.get_users(self.session,top,skip,search) return [serialize_user(u) for u in users] @@ -99,8 +117,10 @@ class User: detail="Incorrect email or password", headers={"WWW-Authenticate":"Bearer"}, ) - if user.is_deleted or not user.is_active: + if user.is_deleted: raise HTTPException(status_code=401,detail="User is inactive") + if not user.is_active: + raise HTTPException(status_code=401,detail="Please confirm your email address to activate your account") return await Users.get_user_by_id(self.session,user.id) async def refresh_access_token(self,refresh_token): diff --git a/devserver.py b/devserver.py index 14ba60b..0b6a39e 100644 --- a/devserver.py +++ b/devserver.py @@ -6,12 +6,26 @@ server answers conditional requests from Last-Modified, which has one-second granularity — so a file edited twice within the same second keeps serving the stale copy and the browser never sees the change. """ +import os import sys from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +# Prefixes served by a client-side router rather than by files on disk. +SPA_ROOTS = ("/auth/",) + class NoCacheHandler(SimpleHTTPRequestHandler): + def send_head(self): + # Deep links like /auth/confirm-email?token=… are router paths, not files. + # Hand back the SPA shell and let react-router resolve them; its assets are + # referenced absolutely (/auth/assets/…), so nothing needs rewriting. + for root in SPA_ROOTS: + if self.path.startswith(root) and not os.path.exists(self.translate_path(self.path)): + self.path = root + "index.html" + break + return super().send_head() + def end_headers(self): self.send_header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0") self.send_header("Pragma", "no-cache") diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..27786df --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:8000 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..27786df --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1 @@ +VITE_API_BASE=http://localhost:8000 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..22480fa --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + TalentFlow · Sign in + + + + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..a35b1d3 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,1897 @@ +{ + "name": "hr-ats-auth", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "hr-ats-auth", + "version": "0.0.1", + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.5.0", + "vite": "^6.3.5" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.8", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.12", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.12.tgz", + "integrity": "sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "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/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.400", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", + "integrity": "sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "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/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.17", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", + "integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==", + "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/node-releases": { + "version": "2.0.52", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.52.tgz", + "integrity": "sha512-MRlTqhAfoMx/4mhEbPo3Hi02g9LJZaJkka69V6h67Cb1gjrAG0jsTE4CZX1eptNx+VCAwJmfpnDIF4P0Nh1A7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "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.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "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.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", + "integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz", + "integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.8" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "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.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.62.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.4", + "@rollup/rollup-android-arm64": "4.62.4", + "@rollup/rollup-darwin-arm64": "4.62.4", + "@rollup/rollup-darwin-x64": "4.62.4", + "@rollup/rollup-freebsd-arm64": "4.62.4", + "@rollup/rollup-freebsd-x64": "4.62.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", + "@rollup/rollup-linux-arm64-gnu": "4.62.4", + "@rollup/rollup-linux-arm64-musl": "4.62.4", + "@rollup/rollup-linux-loong64-gnu": "4.62.4", + "@rollup/rollup-linux-loong64-musl": "4.62.4", + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", + "@rollup/rollup-linux-ppc64-musl": "4.62.4", + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", + "@rollup/rollup-linux-riscv64-musl": "4.62.4", + "@rollup/rollup-linux-s390x-gnu": "4.62.4", + "@rollup/rollup-linux-x64-gnu": "4.62.4", + "@rollup/rollup-linux-x64-musl": "4.62.4", + "@rollup/rollup-openbsd-x64": "4.62.4", + "@rollup/rollup-openharmony-arm64": "4.62.4", + "@rollup/rollup-win32-arm64-msvc": "4.62.4", + "@rollup/rollup-win32-ia32-msvc": "4.62.4", + "@rollup/rollup-win32-x64-gnu": "4.62.4", + "@rollup/rollup-win32-x64-msvc": "4.62.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "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/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "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/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..bccf6a3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,20 @@ +{ + "name": "hr-ats-auth", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.1.0", + "react-dom": "^19.1.0", + "react-router-dom": "^7.6.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.5.0", + "vite": "^6.3.5" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..7961107 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,22 @@ +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom' +import Login from './pages/Login' +import Signup from './pages/Signup' +import ForgotPassword from './pages/ForgotPassword' +import ConfirmEmail from './pages/ConfirmEmail' + +const basename = (import.meta.env.BASE_URL || '/').replace(/\/$/, '') || '/' + +export default function App() { + return ( + + + } /> + } /> + } /> + } /> + } /> + } /> + + + ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..ff319aa --- /dev/null +++ b/frontend/src/api.js @@ -0,0 +1,125 @@ +const API_BASE = import.meta.env.VITE_API_BASE ?? '' + +export class ApiError extends Error { + constructor(message, status, body) { + super(message) + this.name = 'ApiError' + this.status = status + this.body = body + } +} + +function parseDetail(detail) { + if (detail == null || detail === '') return null + if (typeof detail === 'string') return detail + if (Array.isArray(detail)) { + return detail + .map((item) => { + if (typeof item === 'string') return item + if (item && typeof item === 'object') { + return item.msg || item.message || JSON.stringify(item) + } + return String(item) + }) + .filter(Boolean) + .join('; ') + } + if (typeof detail === 'object') { + return detail.msg || detail.message || JSON.stringify(detail) + } + return String(detail) +} + +export function friendlyAuthError(err, fallback = 'Something went wrong. Please try again.') { + if (!(err instanceof ApiError)) { + return err?.message || fallback + } + const fromServer = parseDetail(err.body?.detail) + switch (err.status) { + case 429: + return fromServer || 'Too many attempts. Please wait a moment and try again.' + case 404: + return fromServer || 'No account found for that email.' + case 400: + return fromServer || 'Please check your details and try again.' + case 401: + return fromServer || 'Incorrect email or password.' + case 409: + return fromServer || 'An account with that email already exists.' + case 502: + return fromServer || 'Email service is temporarily unavailable. Please try again later.' + default: + return fromServer || err.message || fallback + } +} + +async function request(path, { method = 'GET', body, token } = {}) { + const headers = { Accept: 'application/json' } + if (body != null) headers['Content-Type'] = 'application/json' + if (token) headers.Authorization = `Bearer ${token}` + + let res + try { + res = await fetch(`${API_BASE}${path}`, { + method, + headers, + body: body != null ? JSON.stringify(body) : undefined, + }) + } catch { + throw new ApiError('Unable to reach the server. Check your connection.', 0, null) + } + + let data = null + const text = await res.text() + if (text) { + try { + data = JSON.parse(text) + } catch { + data = null + } + } + + if (!res.ok) { + throw new ApiError( + parseDetail(data?.detail) || res.statusText || 'Request failed', + res.status, + data, + ) + } + return data +} + +export function login(email, password) { + return request('/users/login', { method: 'POST', body: { email, password } }) +} + +export function signup(name, email, password) { + return request('/users/signup', { method: 'POST', body: { name, email, password } }) +} + +export function confirmEmail(token) { + return request('/users/confirm-email', { method: 'POST', body: { token } }) +} + +export function resendConfirmEmail(email) { + return request('/users/confirm-email/resend', { method: 'POST', body: { email } }) +} + +export function forgetPassword(email) { + return request('/users/forget-password', { method: 'POST', body: { email } }) +} + +export function verifyForgetCode(email, code) { + return request('/users/forget-password/verify-code', { + method: 'POST', + body: { email, code }, + }) +} + +export function setNewPassword(password, resetToken) { + return request('/users/forget-password/new-password', { + method: 'POST', + body: { password }, + token: resetToken, + }) +} diff --git a/frontend/src/auth.css b/frontend/src/auth.css new file mode 100644 index 0000000..a07d95f --- /dev/null +++ b/frontend/src/auth.css @@ -0,0 +1,367 @@ +/* ============================================================ + Auth shell — TalentFlow / Utopia Brands + New classes only. Reuses .btn, .form-field, .card, .brand-*, + .link-btn, .page-title from shared styles.css. + ============================================================ */ + +.auth-shell { + min-height: 100dvh; + display: grid; + grid-template-columns: minmax(280px, 42%) 1fr; + background: var(--bg); +} + +.auth-aside { + position: relative; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 32px; + padding: 40px 44px; + background: var(--brand-green); + color: #fff; + overflow: hidden; +} + +.auth-aside::before { + content: ''; + position: absolute; + inset: auto -20% -30% 20%; + height: 70%; + background: + radial-gradient(ellipse at center, rgba(206, 255, 113, 0.28), transparent 65%); + pointer-events: none; +} + +.auth-aside .auth-brand { + position: relative; + z-index: 1; +} + +.auth-aside .brand-name { + color: #fff; + font-size: 22px; +} + +.auth-aside-copy { + position: relative; + z-index: 1; + max-width: 28ch; +} + +.auth-aside-copy h1 { + font-family: 'Belleza', Georgia, serif; + font-size: clamp(32px, 4vw, 44px); + line-height: 1.15; + font-weight: 400; + letter-spacing: -0.02em; + margin: 0 0 14px; + color: #fff; +} + +.auth-aside-copy p { + margin: 0; + font-size: 15px; + line-height: 1.55; + color: rgba(255, 255, 255, 0.78); +} + +.auth-aside-foot { + position: relative; + z-index: 1; + margin: 0; + font-size: 12px; + letter-spacing: 0.4px; + color: var(--brand-lime); +} + +.auth-panel { + display: flex; + flex-direction: column; + justify-content: center; + padding: 32px clamp(20px, 5vw, 64px); + position: relative; +} + +.auth-panel-top { + position: absolute; + top: 20px; + right: 20px; + left: 20px; + display: flex; + align-items: center; + justify-content: space-between; +} + +.auth-panel-brand { + display: none; +} + +.auth-brand { + display: flex; + align-items: center; + gap: 12px; +} + +.auth-brand .brand-logo { + width: 42px; + height: 42px; +} + +.auth-brand .brand-logo-lg { + width: 48px; + height: 48px; + border-radius: 12px; +} + +.auth-brand .brand-name { + font-family: 'Belleza', Georgia, serif; + font-weight: 400; + letter-spacing: 0.2px; +} + +.auth-card { + width: 100%; + max-width: 420px; + margin: 0 auto; + padding: 32px 28px 28px; +} + +.auth-card .page-title { + margin: 0 0 8px; + font-size: 28px; +} + +.auth-sub { + margin: 0 0 22px; + color: var(--text-2); + font-size: 14px; + line-height: 1.45; +} + +.auth-card .form-field { + margin-bottom: 14px; +} + +.auth-row-end { + display: flex; + justify-content: flex-end; + margin: -4px 0 4px; +} + +.auth-foot { + max-width: 420px; + margin: 18px auto 0; + text-align: center; + font-size: 13.5px; + color: var(--text-2); +} + +.auth-resend { + margin-top: 14px; + text-align: center; +} + +.auth-theme-toggle { + width: 40px; + height: 40px; + border-radius: 10px; + display: grid; + place-items: center; + color: var(--text-2); + background: var(--bg-elev); + border: 1px solid var(--border); + transition: 0.15s; +} + +.auth-theme-toggle:hover { + background: var(--bg-sunken); + color: var(--text); +} + +.auth-theme-toggle svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ---- Alerts ---- */ +.alert { + padding: 11px 14px; + border-radius: 10px; + font-size: 13px; + line-height: 1.45; + margin-bottom: 16px; + border: 1px solid transparent; +} + +.alert-danger { + background: var(--danger-soft); + color: var(--danger); + border-color: color-mix(in srgb, var(--danger) 25%, transparent); +} + +.alert-success { + background: var(--success-soft); + color: var(--success); + border-color: color-mix(in srgb, var(--success) 25%, transparent); +} + +/* ---- Password field ---- */ +.pw-wrap { + position: relative; + display: flex; + align-items: center; +} + +.pw-wrap input { + width: 100%; + padding-right: 44px; +} + +.pw-toggle { + position: absolute; + right: 8px; + width: 34px; + height: 34px; + border-radius: 8px; + display: grid; + place-items: center; + color: var(--text-3); + transition: 0.15s; +} + +.pw-toggle:hover { + color: var(--text); + background: var(--bg-sunken); +} + +.pw-toggle svg { + width: 18px; + height: 18px; + fill: none; + stroke: currentColor; + stroke-width: 2; + stroke-linecap: round; + stroke-linejoin: round; +} + +/* ---- OTP ---- */ +.otp-row { + display: flex; + gap: 8px; + justify-content: space-between; + margin-bottom: 8px; +} + +.otp-input { + width: 100%; + max-width: 52px; + aspect-ratio: 1; + text-align: center; + font-size: 20px; + font-weight: 700; + letter-spacing: 0; + border: 1px solid var(--border-strong); + border-radius: 10px; + background: var(--bg-elev); + color: var(--text); + outline: none; + transition: 0.15s; +} + +.otp-input:focus { + border-color: var(--primary); + box-shadow: var(--ring); +} + +.otp-input.err { + border-color: var(--danger); +} + +/* ---- Countdown / spinner ---- */ +.countdown { + margin: 8px 0 4px; + font-size: 13px; + color: var(--text-2); + text-align: center; +} + +.countdown strong { + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.spinner { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; +} + +.spinner-dot { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--primary-fg) 35%, transparent); + border-top-color: var(--primary-fg); + animation: auth-spin 0.7s linear infinite; +} + +@keyframes auth-spin { + to { + transform: rotate(360deg); + } +} + +.btn-primary .spinner-dot { + border-color: color-mix(in srgb, var(--primary-fg) 35%, transparent); + border-top-color: var(--primary-fg); +} + +/* ---- Responsive ---- */ +@media (max-width: 860px) { + .auth-shell { + grid-template-columns: 1fr; + } + + .auth-aside { + display: none; + } + + .auth-panel-brand { + display: block; + } + + .auth-panel-brand .brand-name { + color: var(--text); + } + + .auth-panel { + padding-top: 88px; + min-height: 100dvh; + } + + .auth-card { + box-shadow: none; + border: none; + background: transparent; + padding: 8px 0 24px; + } +} + +@media (max-width: 420px) { + .otp-row { + gap: 6px; + } + + .otp-input { + max-width: 46px; + font-size: 18px; + } +} diff --git a/frontend/src/auth.js b/frontend/src/auth.js new file mode 100644 index 0000000..9fe3d04 --- /dev/null +++ b/frontend/src/auth.js @@ -0,0 +1,30 @@ +const STORAGE_KEY = 'tf-auth' + +export function getSession() { + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (!raw) return null + return JSON.parse(raw) + } catch { + return null + } +} + +export function setSession(session) { + const payload = { + access_token: session.access_token, + refresh_token: session.refresh_token, + expires_in: session.expires_in, + data: session.data, + } + localStorage.setItem(STORAGE_KEY, JSON.stringify(payload)) + return payload +} + +export function clearSession() { + try { + localStorage.removeItem(STORAGE_KEY) + } catch { + /* ignore */ + } +} diff --git a/frontend/src/components/Alert.jsx b/frontend/src/components/Alert.jsx new file mode 100644 index 0000000..15a4968 --- /dev/null +++ b/frontend/src/components/Alert.jsx @@ -0,0 +1,9 @@ +export default function Alert({ type = 'danger', children }) { + if (!children) return null + const cls = type === 'success' ? 'alert alert-success' : 'alert alert-danger' + return ( +
+ {children} +
+ ) +} diff --git a/frontend/src/components/AuthLayout.jsx b/frontend/src/components/AuthLayout.jsx new file mode 100644 index 0000000..a231ba2 --- /dev/null +++ b/frontend/src/components/AuthLayout.jsx @@ -0,0 +1,67 @@ +import { useState } from 'react' +import BrandMark from './BrandMark' +import ThemeToggle from './ThemeToggle' + +export default function AuthLayout({ title, subtitle, children, foot }) { + return ( +
+ + +
+
+
+ +
+ +
+ +
+ {title ?

{title}

: null} + {subtitle ?

{subtitle}

: null} + {children} +
+ + {foot ?
{foot}
: null} +
+
+ ) +} + +export function useFormState(initial = {}) { + const [values, setValues] = useState(initial) + const [errors, setErrors] = useState({}) + const [alert, setAlert] = useState(null) + const [busy, setBusy] = useState(false) + + function setField(name, value) { + setValues((prev) => ({ ...prev, [name]: value })) + setErrors((prev) => { + if (!prev[name]) return prev + const next = { ...prev } + delete next[name] + return next + }) + } + + return { + values, + setValues, + setField, + errors, + setErrors, + alert, + setAlert, + busy, + setBusy, + } +} diff --git a/frontend/src/components/BrandMark.jsx b/frontend/src/components/BrandMark.jsx new file mode 100644 index 0000000..352f37f --- /dev/null +++ b/frontend/src/components/BrandMark.jsx @@ -0,0 +1,13 @@ +export default function BrandMark({ size = 'md', showName = true }) { + const logoClass = size === 'lg' ? 'brand-logo brand-logo-lg' : 'brand-logo' + return ( +
+
+ +
+ {showName ? TalentFlow : null} +
+ ) +} diff --git a/frontend/src/components/Countdown.jsx b/frontend/src/components/Countdown.jsx new file mode 100644 index 0000000..f24bfbe --- /dev/null +++ b/frontend/src/components/Countdown.jsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' + +function formatSeconds(total) { + const s = Math.max(0, Math.floor(total)) + const m = Math.floor(s / 60) + const r = s % 60 + return `${m}:${String(r).padStart(2, '0')}` +} + +/** + * Countdown seeded from a server duration (seconds). + * Pass `expiresIn` from the API — never invent a client-side default window. + */ +export default function Countdown({ expiresIn, startedAt, onExpire, label = 'Code expires in' }) { + const [remaining, setRemaining] = useState(() => { + if (expiresIn == null || !Number.isFinite(Number(expiresIn))) return null + const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0 + return Math.max(0, Number(expiresIn) - elapsed) + }) + + useEffect(() => { + if (expiresIn == null || !Number.isFinite(Number(expiresIn))) { + setRemaining(null) + return undefined + } + const tick = () => { + const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0 + const left = Math.max(0, Number(expiresIn) - elapsed) + setRemaining(left) + if (left <= 0 && onExpire) onExpire() + } + tick() + const id = setInterval(tick, 250) + return () => clearInterval(id) + }, [expiresIn, startedAt, onExpire]) + + if (remaining == null) return null + + return ( +

+ {remaining > 0 ? ( + <> + {label} {formatSeconds(remaining)} + + ) : ( + Code expired + )} +

+ ) +} + +export function useResendGate(resendAfter, startedAt) { + const [remaining, setRemaining] = useState(() => { + if (resendAfter == null || !Number.isFinite(Number(resendAfter))) return 0 + const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0 + return Math.max(0, Number(resendAfter) - elapsed) + }) + + useEffect(() => { + if (resendAfter == null || !Number.isFinite(Number(resendAfter))) { + setRemaining(0) + return undefined + } + const tick = () => { + const elapsed = startedAt ? (Date.now() - startedAt) / 1000 : 0 + setRemaining(Math.max(0, Number(resendAfter) - elapsed)) + } + tick() + const id = setInterval(tick, 250) + return () => clearInterval(id) + }, [resendAfter, startedAt]) + + return remaining +} diff --git a/frontend/src/components/OtpInput.jsx b/frontend/src/components/OtpInput.jsx new file mode 100644 index 0000000..a4dd5ea --- /dev/null +++ b/frontend/src/components/OtpInput.jsx @@ -0,0 +1,92 @@ +import { useEffect, useRef } from 'react' + +const LENGTH = 6 + +export default function OtpInput({ value = '', onChange, disabled = false, error = false }) { + const refs = useRef([]) + const digits = Array.from({ length: LENGTH }, (_, i) => value[i] || '') + + useEffect(() => { + refs.current = refs.current.slice(0, LENGTH) + }, []) + + function commit(nextDigits) { + onChange(nextDigits.join('').slice(0, LENGTH)) + } + + function setAt(index, char) { + const next = digits.slice() + next[index] = char + commit(next) + } + + function handleChange(index, e) { + const raw = e.target.value.replace(/\D/g, '') + if (!raw) { + setAt(index, '') + return + } + if (raw.length > 1) { + const chars = raw.slice(0, LENGTH - index).split('') + const next = digits.slice() + chars.forEach((c, i) => { + next[index + i] = c + }) + commit(next) + const focusIdx = Math.min(index + chars.length, LENGTH - 1) + refs.current[focusIdx]?.focus() + return + } + setAt(index, raw) + if (index < LENGTH - 1) refs.current[index + 1]?.focus() + } + + function handleKeyDown(index, e) { + if (e.key === 'Backspace' && !digits[index] && index > 0) { + e.preventDefault() + setAt(index - 1, '') + refs.current[index - 1]?.focus() + } + if (e.key === 'ArrowLeft' && index > 0) { + e.preventDefault() + refs.current[index - 1]?.focus() + } + if (e.key === 'ArrowRight' && index < LENGTH - 1) { + e.preventDefault() + refs.current[index + 1]?.focus() + } + } + + function handlePaste(e) { + e.preventDefault() + const pasted = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, LENGTH) + if (!pasted) return + const next = Array.from({ length: LENGTH }, (_, i) => pasted[i] || '') + commit(next) + refs.current[Math.min(pasted.length, LENGTH) - 1]?.focus() + } + + return ( +
+ {digits.map((digit, i) => ( + { + refs.current[i] = el + }} + className={`otp-input${error ? ' err' : ''}`} + type="text" + inputMode="numeric" + autoComplete={i === 0 ? 'one-time-code' : 'off'} + maxLength={1} + value={digit} + disabled={disabled} + aria-label={`Digit ${i + 1}`} + onChange={(e) => handleChange(i, e)} + onKeyDown={(e) => handleKeyDown(i, e)} + onPaste={handlePaste} + /> + ))} +
+ ) +} diff --git a/frontend/src/components/PasswordField.jsx b/frontend/src/components/PasswordField.jsx new file mode 100644 index 0000000..e94b72f --- /dev/null +++ b/frontend/src/components/PasswordField.jsx @@ -0,0 +1,56 @@ +import { useId, useState } from 'react' + +export default function PasswordField({ + id, + label = 'Password', + value, + onChange, + error, + autoComplete = 'current-password', + placeholder = '••••••••', + required = true, + disabled = false, +}) { + const autoId = useId() + const fieldId = id || autoId + const [visible, setVisible] = useState(false) + + return ( +
+ +
+ onChange(e.target.value)} + autoComplete={autoComplete} + placeholder={placeholder} + className={error ? 'err' : ''} + required={required} + disabled={disabled} + /> + +
+ {error || ''} +
+ ) +} diff --git a/frontend/src/components/Spinner.jsx b/frontend/src/components/Spinner.jsx new file mode 100644 index 0000000..95fa8ed --- /dev/null +++ b/frontend/src/components/Spinner.jsx @@ -0,0 +1,7 @@ +export default function Spinner({ label = 'Loading' }) { + return ( + + + + ) +} diff --git a/frontend/src/components/ThemeToggle.jsx b/frontend/src/components/ThemeToggle.jsx new file mode 100644 index 0000000..515f83a --- /dev/null +++ b/frontend/src/components/ThemeToggle.jsx @@ -0,0 +1,46 @@ +import { useEffect, useState } from 'react' +import { toggleTheme } from '../theme' + +export default function ThemeToggle() { + const [theme, setTheme] = useState( + () => document.documentElement.getAttribute('data-theme') || 'light', + ) + + useEffect(() => { + setTheme(document.documentElement.getAttribute('data-theme') || 'light') + }, []) + + function onClick() { + setTheme(toggleTheme()) + } + + const dark = theme === 'dark' + return ( + + ) +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..ce95081 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,14 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import '@shared-css' +import './auth.css' +import { initTheme } from './theme' +import App from './App' + +initTheme() + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/frontend/src/pages/ConfirmEmail.jsx b/frontend/src/pages/ConfirmEmail.jsx new file mode 100644 index 0000000..64da057 --- /dev/null +++ b/frontend/src/pages/ConfirmEmail.jsx @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { Link, useSearchParams } from 'react-router-dom' +import AuthLayout from '../components/AuthLayout' +import Alert from '../components/Alert' +import Spinner from '../components/Spinner' +import { confirmEmail, resendConfirmEmail, friendlyAuthError } from '../api' + +const titles = { + verifying: 'Confirming your email', + success: 'Email confirmed', + error: 'Confirmation failed', +} + +const subtitles = { + verifying: 'Hold on while we activate your account.', + success: 'Your TalentFlow account is active.', + error: 'That link did not work. Request a new one below.', +} + +export default function ConfirmEmail() { + const [params] = useSearchParams() + const token = params.get('token') || '' + + const [state, setState] = useState('verifying') + const [alert, setAlert] = useState(null) + const [email, setEmail] = useState('') + const [emailError, setEmailError] = useState('') + const [busy, setBusy] = useState(false) + const [resent, setResent] = useState(false) + + // StrictMode runs mount effects twice in dev; the ref survives the remount so + // the POST fires once. The server is idempotent either way. + const firedRef = useRef(false) + + const runConfirm = useCallback(async () => { + if (!token) { + setState('error') + setAlert({ type: 'danger', message: 'This confirmation link is missing its token.' }) + return + } + try { + const res = await confirmEmail(token) + const data = res?.data || {} + if (data.email) setEmail(data.email) + setState('success') + setAlert({ + type: 'success', + message: data.already_confirmed + ? 'Your email was already confirmed. You can sign in.' + : 'Your email is confirmed. You can sign in now.', + }) + } catch (err) { + setState('error') + setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not confirm your email.') }) + } + }, [token]) + + useEffect(() => { + if (firedRef.current) return + firedRef.current = true + runConfirm() + }, [runConfirm]) + + async function onResend(e) { + e.preventDefault() + if (!email.trim()) { + setEmailError('Email is required') + return + } + setEmailError('') + setAlert(null) + setBusy(true) + try { + await resendConfirmEmail(email.trim()) + setResent(true) + setAlert({ type: 'success', message: 'We sent a new confirmation link to your email.' }) + } catch (err) { + setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not send a new link.') }) + } finally { + setBusy(false) + } + } + + return ( + + Back to{' '} + + Sign in + + + } + > + {alert?.message} + + {state === 'verifying' && ( +

+ Confirming… +

+ )} + + {state === 'success' && ( + + Sign in + + )} + + {state === 'error' && !resent && ( +
+
+ + { + setEmail(e.target.value) + setEmailError('') + }} + className={emailError ? 'err' : ''} + placeholder="you@utopiabrands.com" + disabled={busy} + required + /> + {emailError || ''} +
+ + +
+ )} +
+ ) +} diff --git a/frontend/src/pages/ForgotPassword.jsx b/frontend/src/pages/ForgotPassword.jsx new file mode 100644 index 0000000..479927c --- /dev/null +++ b/frontend/src/pages/ForgotPassword.jsx @@ -0,0 +1,286 @@ +import { useCallback, useState } from 'react' +import { Link } from 'react-router-dom' +import AuthLayout from '../components/AuthLayout' +import PasswordField from '../components/PasswordField' +import Alert from '../components/Alert' +import OtpInput from '../components/OtpInput' +import Countdown, { useResendGate } from '../components/Countdown' +import Spinner from '../components/Spinner' +import { + forgetPassword, + verifyForgetCode, + setNewPassword, + friendlyAuthError, +} from '../api' + +function pickTiming(payload) { + const src = payload?.data && typeof payload.data === 'object' ? payload.data : payload || {} + const expiresIn = src.expires_in + const resendAfter = src.resend_after + return { + expiresIn: expiresIn != null && Number.isFinite(Number(expiresIn)) ? Number(expiresIn) : null, + resendAfter: + resendAfter != null && Number.isFinite(Number(resendAfter)) ? Number(resendAfter) : null, + } +} + +function pickResetToken(payload) { + if (!payload) return null + if (payload.reset_token) return payload.reset_token + if (payload.access_token) return payload.access_token + if (payload.data?.reset_token) return payload.data.reset_token + if (payload.data?.access_token) return payload.data.access_token + return null +} + +export default function ForgotPassword() { + const [step, setStep] = useState(1) + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [errors, setErrors] = useState({}) + const [alert, setAlert] = useState(null) + const [busy, setBusy] = useState(false) + const [expiresIn, setExpiresIn] = useState(null) + const [resendAfter, setResendAfter] = useState(null) + const [startedAt, setStartedAt] = useState(null) + const [resetToken, setResetToken] = useState(null) + const [expired, setExpired] = useState(false) + + const resendLeft = useResendGate(resendAfter, startedAt) + const onExpire = useCallback(() => setExpired(true), []) + + function applyChallengeTiming(payload) { + const { expiresIn: exp, resendAfter: ra } = pickTiming(payload) + setExpiresIn(exp) + setResendAfter(ra) + setStartedAt(Date.now()) + setExpired(false) + } + + async function requestCode(e) { + e?.preventDefault() + const nextErrors = {} + if (!email.trim()) nextErrors.email = 'Email is required' + setErrors(nextErrors) + if (Object.keys(nextErrors).length) return + + setAlert(null) + setBusy(true) + try { + const res = await forgetPassword(email.trim()) + applyChallengeTiming(res) + setCode('') + setStep(2) + setAlert({ + type: 'success', + message: res?.detail || res?.message || 'We sent a 6-digit code to your email.', + }) + } catch (err) { + setAlert({ type: 'danger', message: friendlyAuthError(err) }) + } finally { + setBusy(false) + } + } + + async function verifyCode(e) { + e.preventDefault() + const nextErrors = {} + if (!/^\d{6}$/.test(code)) nextErrors.code = 'Enter the 6-digit code' + setErrors(nextErrors) + if (Object.keys(nextErrors).length) return + if (expired) { + setAlert({ type: 'danger', message: 'That code has expired. Request a new one.' }) + return + } + + setAlert(null) + setBusy(true) + try { + const res = await verifyForgetCode(email.trim(), code) + const token = pickResetToken(res) + if (!token) { + setAlert({ type: 'danger', message: 'Verification succeeded but no reset token was returned.' }) + return + } + setResetToken(token) + setStep(3) + setAlert(null) + } catch (err) { + setAlert({ type: 'danger', message: friendlyAuthError(err, 'Invalid or expired code.') }) + } finally { + setBusy(false) + } + } + + async function submitPassword(e) { + e.preventDefault() + const nextErrors = {} + if (!password) nextErrors.password = 'Password is required' + else if (password.length < 8) nextErrors.password = 'Use at least 8 characters' + if (confirm !== password) nextErrors.confirm = 'Passwords do not match' + setErrors(nextErrors) + if (Object.keys(nextErrors).length) return + + setAlert(null) + setBusy(true) + try { + await setNewPassword(password, resetToken) + setAlert({ + type: 'success', + message: 'Password updated. You can sign in with your new password.', + }) + setStep(4) + } catch (err) { + setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not update password.') }) + } finally { + setBusy(false) + } + } + + const titles = { + 1: 'Forgot password', + 2: 'Enter code', + 3: 'New password', + 4: 'Password updated', + } + const subtitles = { + 1: 'We’ll email you a one-time code to reset access.', + 2: `Enter the 6-digit code sent to ${email}.`, + 3: 'Choose a new password for your account.', + 4: 'You’re all set.', + } + + return ( + + Back to sign in + + ) : ( + <> + Remembered it?{' '} + + Sign in + + + ) + } + > + {alert?.message} + + {step === 1 && ( +
+
+ + { + setEmail(e.target.value) + setErrors((prev) => { + if (!prev.email) return prev + const n = { ...prev } + delete n.email + return n + }) + }} + className={errors.email ? 'err' : ''} + placeholder="you@utopiabrands.com" + disabled={busy} + required + /> + {errors.email || ''} +
+ +
+ )} + + {step === 2 && ( +
+ { + setCode(v) + setErrors((prev) => { + if (!prev.code) return prev + const n = { ...prev } + delete n.code + return n + }) + }} + disabled={busy} + error={Boolean(errors.code)} + /> + {errors.code || ''} + + + + + +
+ {resendLeft > 0 ? ( + Resend available in {Math.ceil(resendLeft)}s + ) : ( + + )} +
+ + )} + + {step === 3 && ( +
+ + + + + )} + + {step === 4 && ( + + Sign in + + )} +
+ ) +} diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..6f5178b --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,93 @@ +import { Link } from 'react-router-dom' +import AuthLayout, { useFormState } from '../components/AuthLayout' +import PasswordField from '../components/PasswordField' +import Alert from '../components/Alert' +import Spinner from '../components/Spinner' +import { login, friendlyAuthError } from '../api' +import { setSession } from '../auth' + +function goToApp() { + window.location.assign('/index.html#dashboard') +} + +export default function Login() { + const form = useFormState({ email: '', password: '' }) + + async function onSubmit(e) { + e.preventDefault() + const errors = {} + if (!form.values.email.trim()) errors.email = 'Email is required' + if (!form.values.password) errors.password = 'Password is required' + form.setErrors(errors) + if (Object.keys(errors).length) return + + form.setAlert(null) + form.setBusy(true) + try { + const res = await login(form.values.email.trim(), form.values.password) + setSession(res) + goToApp() + } catch (err) { + form.setAlert({ type: 'danger', message: friendlyAuthError(err, 'Could not sign in.') }) + form.setBusy(false) + } + } + + return ( + + New here?{' '} + + Create an account + + + } + > +
+ {form.alert?.message} + +
+ + form.setField('email', e.target.value)} + className={form.errors.email ? 'err' : ''} + placeholder="you@utopiabrands.com" + disabled={form.busy} + required + /> + + {form.errors.email || ''} + +
+ + form.setField('password', v)} + error={form.errors.password} + disabled={form.busy} + /> + +
+ + Forgot password? + +
+ + + +
+ ) +} diff --git a/frontend/src/pages/Signup.jsx b/frontend/src/pages/Signup.jsx new file mode 100644 index 0000000..16712b6 --- /dev/null +++ b/frontend/src/pages/Signup.jsx @@ -0,0 +1,211 @@ +import { useState } from 'react' +import { Link } from 'react-router-dom' +import AuthLayout, { useFormState } from '../components/AuthLayout' +import PasswordField from '../components/PasswordField' +import Alert from '../components/Alert' +import Spinner from '../components/Spinner' +import { useResendGate } from '../components/Countdown' +import { signup, resendConfirmEmail, friendlyAuthError, ApiError } from '../api' +import { setSession } from '../auth' + +function goToApp() { + window.location.assign('/index.html#dashboard') +} + +export default function Signup() { + const form = useFormState({ name: '', email: '', password: '', confirm: '' }) + const [sent, setSent] = useState(false) + const [resendAfter, setResendAfter] = useState(null) + const [startedAt, setStartedAt] = useState(null) + const resendLeft = useResendGate(resendAfter, startedAt) + + async function onResend() { + form.setAlert(null) + form.setBusy(true) + try { + const res = await resendConfirmEmail(form.values.email.trim()) + const data = res?.data || {} + setResendAfter(Number.isFinite(Number(data.resend_after)) ? Number(data.resend_after) : null) + setStartedAt(Date.now()) + form.setAlert({ type: 'success', message: 'We sent a new confirmation link.' }) + } catch (err) { + form.setAlert({ + type: 'danger', + message: friendlyAuthError(err, 'Could not resend the link.'), + }) + } finally { + form.setBusy(false) + } + } + + async function onSubmit(e) { + e.preventDefault() + const errors = {} + if (!form.values.name.trim()) errors.name = 'Name is required' + if (!form.values.email.trim()) errors.email = 'Email is required' + if (!form.values.password) errors.password = 'Password is required' + else if (form.values.password.length < 8) errors.password = 'Use at least 8 characters' + if (form.values.confirm !== form.values.password) errors.confirm = 'Passwords do not match' + form.setErrors(errors) + if (Object.keys(errors).length) return + + form.setAlert(null) + form.setBusy(true) + try { + const res = await signup( + form.values.name.trim(), + form.values.email.trim(), + form.values.password, + ) + // The account starts inactive; only the mailed link activates it, so the + // tokens in this response are unusable until then. + if (!res?.data?.is_active) { + setSent(true) + form.setAlert({ + type: 'success', + message: `We sent a confirmation link to ${res?.data?.email || form.values.email.trim()}.`, + }) + form.setBusy(false) + return + } + setSession(res) + goToApp() + } catch (err) { + // 502 means the account was created but the email failed — offer a resend + // rather than a dead end. + if (err instanceof ApiError && err.status === 502) { + setSent(true) + form.setAlert({ + type: 'danger', + message: 'Your account was created but we could not send the email. Try again below.', + }) + form.setBusy(false) + return + } + form.setAlert({ + type: 'danger', + message: friendlyAuthError(err, 'Could not create your account.'), + }) + form.setBusy(false) + } + } + + if (sent) { + return ( + + Already confirmed?{' '} + + Sign in + + + } + > + {form.alert?.message} + + {resendLeft > 0 ? ( +

+ Resend available in {Math.ceil(resendLeft)}s +

+ ) : ( + + )} +
+ ) + } + + return ( + + Already have an account?{' '} + + Sign in + + + } + > +
+ {form.alert?.message} + +
+ + form.setField('name', e.target.value)} + className={form.errors.name ? 'err' : ''} + placeholder="Asfand Ahmed" + disabled={form.busy} + required + /> + + {form.errors.name || ''} + +
+ +
+ + form.setField('email', e.target.value)} + className={form.errors.email ? 'err' : ''} + placeholder="you@utopiabrands.com" + disabled={form.busy} + required + /> + + {form.errors.email || ''} + +
+ + form.setField('password', v)} + error={form.errors.password} + disabled={form.busy} + /> + + form.setField('confirm', v)} + error={form.errors.confirm} + disabled={form.busy} + /> + + + +
+ ) +} diff --git a/frontend/src/theme.js b/frontend/src/theme.js new file mode 100644 index 0000000..dbcecca --- /dev/null +++ b/frontend/src/theme.js @@ -0,0 +1,32 @@ +const THEME_KEY = 'tf-theme' + +export function applyTheme(theme, persist = true) { + document.documentElement.setAttribute('data-theme', theme) + if (persist) { + try { + localStorage.setItem(THEME_KEY, theme) + } catch { + /* ignore */ + } + } +} + +export function getStoredTheme() { + try { + return localStorage.getItem(THEME_KEY) + } catch { + return null + } +} + +export function initTheme() { + const mq = window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null + const saved = getStoredTheme() + applyTheme(saved || (mq && mq.matches ? 'dark' : 'light'), false) +} + +export function toggleTheme() { + const cur = document.documentElement.getAttribute('data-theme') + applyTheme(cur === 'dark' ? 'light' : 'dark', true) + return document.documentElement.getAttribute('data-theme') +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..6299c1e --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,15 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'path' +import { fileURLToPath } from 'url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..') + +export default defineConfig({ + plugins: [react()], + base: '/auth/', + build: { outDir: path.resolve(repoRoot, 'auth'), emptyOutDir: true }, + resolve: { alias: { '@shared-css': path.resolve(repoRoot, 'css/styles.css') } }, + server: { port: 5173, fs: { allow: [repoRoot] } }, +}) diff --git a/index.html b/index.html index e24d668..44f431a 100644 --- a/index.html +++ b/index.html @@ -233,7 +233,7 @@ Settings Help Center - + diff --git a/js/app.js b/js/app.js index 5978053..a1ffce7 100644 --- a/js/app.js +++ b/js/app.js @@ -79,6 +79,44 @@ App.toggleTheme = function () { App.setTheme(cur === 'dark' ? 'light' : 'dark'); }; +// ---------------- Session (tf-auth from /auth/) ---------------- +App.getSession = function () { + try { + const raw = localStorage.getItem('tf-auth'); + if (!raw) return null; + return JSON.parse(raw); + } catch (e) { + return null; + } +}; + +function initialsFromName(name) { + const parts = String(name || '').trim().split(/\s+/).filter(Boolean); + if (!parts.length) return '?'; + if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); +} + +App.hydrateProfile = function () { + const session = App.getSession(); + const data = (session && session.data) || {}; + const name = data.name || 'Guest'; + const email = data.email || ''; + const role = data.role_name || data.role || 'Member'; + const initials = initialsFromName(name); + + document.querySelectorAll('.profile-name').forEach(el => { el.textContent = name; }); + document.querySelectorAll('.profile-role').forEach(el => { el.textContent = role; }); + document.querySelectorAll('.dp-name').forEach(el => { el.textContent = name; }); + document.querySelectorAll('.dp-email').forEach(el => { el.textContent = email; }); + document.querySelectorAll('.avatar-grad').forEach(el => { el.textContent = initials; }); +}; + +App.signOut = function () { + try { localStorage.removeItem('tf-auth'); } catch (e) {} + window.location.assign('/auth/'); +}; + // ---------------- Toast passthrough ---------------- App.toast = function (msg, type, title) { UI.toast(msg, type, title); }; @@ -263,6 +301,7 @@ function init() { initDropdowns(); initNav(); + App.hydrateProfile(); App.renderNotifDropdown(); App.renderMessages(); App.updateBadges();