diff --git a/.env.example b/.env.example index c3ba802..87c678a 100644 --- a/.env.example +++ b/.env.example @@ -3,6 +3,13 @@ PORT=4000 # Database DATABASE_URL="postgresql://使用者:密碼@localhost:5432/你的資料庫名" + +# E2E 測試專用資料庫(本機、可拋棄)。 +# `pnpm test:e2e` 只會連這條,且會清空整個 DB,所以: +# - 必須指向本機 Postgres(localhost / 127.0.0.1),否則 e2e 會直接中止報錯 +# - 絕對不要填遠端 / 正式 DB 的連線字串 +# - 先建立一個空的測試資料庫:createdb form_platform_test +TEST_DATABASE_URL="postgresql://使用者:密碼@localhost:5432/form_platform_test" JWT_SECRET="dev-secret-change-me" CLIENT_URL="http://localhost:3000" diff --git a/.gitignore b/.gitignore index bed8421..aeed0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -57,3 +57,8 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json # Prisma Client /generated/prisma + +# secret backups — never track +*.env.bak +.env.bak.* +.env.bak diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f41575f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,94 @@ +# AGENTS.md + +This file provides guidance to Codex (Codex.ai/code) when working with code in this repository. + +## Commands + +```bash +# Install dependencies +pnpm install + +# Development (watch mode) +pnpm start:dev + +# Build +pnpm build + +# Lint (auto-fix) +pnpm lint + +# Unit tests +pnpm test + +# Run a single test file +pnpm test -- --testPathPattern=activity + +# E2E tests +pnpm test:e2e + +# Database migrations +npx prisma migrate dev + +# Seed database +npx prisma db seed + +# Generate Prisma client (after schema changes) +npx prisma generate +``` + +## Environment Variables + +Required in `.env`: +- `DATABASE_URL` — PostgreSQL connection string +- `JWT_SECRET` — Secret for signing JWTs +- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`, `GITHUB_REDIRECT_URI` — GitHub OAuth app credentials +- `CLIENT_URL` — Frontend origin for CORS and OAuth redirect (default: `http://localhost:3000`) +- `PORT` — Server port (default: `4000`) + +## Architecture + +This is a NestJS + Prisma + PostgreSQL API server for a conference CFP (Call for Papers) platform. + +**Global setup (`src/main.ts`):** The app runs on `api/v1` prefix. `JwtAuthGuard` and `PermissionGuard` are applied globally — all routes require a valid JWT unless decorated with `@Public()`. + +**Auth flow (`src/auth/`):** +- GitHub OAuth via the `arctic` library. Callback sets an `access_token` httpOnly cookie containing a JWT. +- JWT only carries `{ sub: memberId, v: tokenVersion }` — **no permissions in the token**. Permissions are stored in an in-memory cache (`@nestjs/cache-manager`) and loaded by `JwtStrategy.validate()` on every request. +- Cache miss fallback: if permissions aren't cached, `JwtStrategy` queries the database and populates the cache. +- `POST /auth/dev-login` is available in non-production for quick local testing without GitHub OAuth. + +**Scoped RBAC (`src/auth/`):** +- Three permission scopes: `PLATFORM` (global), `ORG` (organization-level), `EVENT` (activity-level). +- `AuthService.getScopedPermissions()` returns `{ platform: string[], org: Record, event: Record }`. +- **ORG permissions cascade to all events under that organization** — the guard resolves `activity → organization` in real-time and merges org permissions into the event scope. +- To mark a route public: use the `@Public()` decorator. +- To require permissions in a specific scope: use `@RequirePermissions({ scope: "EVENT", param: "activityId", perms: ["event:edit"] })`. +- Legacy `@Permissions('permission:code')` still works — treated as `PLATFORM` scope. + +**Permission invalidation:** +- Call `AuthService.bumpTokenVersion(memberId)` to revoke all tokens for a user. This increments `tokenVersion`, invalidates the cache, and revokes all refresh tokens. Existing JWTs immediately receive 401. + +**Permission codes (seed):** +- `org:profile`, `org:finance`, `org:member:manage`, `org:report` +- `event:edit`, `event:registration:read`, `event:checkin`, `event:order:manage` +- `activity:manage`, `permission:manage`, `role:manage` (platform) + +**Permission model:** `Member` → `Membership` (join with scope) → `Role` → `RolePermission` (join) → `Permission`. +- `Membership` includes `scopeType` (PLATFORM | ORG | EVENT) and optional `organizationId` / `activityId` FKs. +- Permissions use colon-namespaced codes (e.g., `event:edit`). Seed creates scoped roles: + - PLATFORM: `admin` (all platform permissions) + - ORG: `Owner`, `Admin`, `Accountant` + - EVENT: `Admin`, `Creator`, `Accountant`, `Checkin`, `Streaming` + +**Prisma setup:** +- Schema is split across `prisma/models/*.prisma` files; `prisma.config.ts` points Prisma at the `prisma/` directory. +- Generated client lives in `generated/prisma/` (not `node_modules`). +- Uses the `@prisma/adapter-pg` driver adapter (connection pool via `pg`). +- All IDs are UUIDv7 strings. Use the `withId()` helper from `src/common/utils/db.util.ts` when creating records. + +**Activity module (`src/activity/`):** Demonstrates the standard pattern: +- Public route: `GET /activities/slug/:slug` — uses `@Public()`, returns only `supportedLanguages`-filtered contents. +- Admin routes: require `activity:manage` permission via `@Permissions(...)`. +- `ActivityContent` is the i18n table; each `Activity` has multiple `ActivityContent` rows keyed by `lang`. + +**Swagger:** Available at `/docs` in development. diff --git a/CLAUDE.md b/CLAUDE.md index 30430ce..c7e2382 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,11 +53,32 @@ This is a NestJS + Prisma + PostgreSQL API server for a conference CFP (Call for **Auth flow (`src/auth/`):** - GitHub OAuth via the `arctic` library. Callback sets an `access_token` httpOnly cookie containing a JWT. -- JWT payload carries `{ sub: memberId, email, permissions: string[] }`. Permissions are resolved at login time by walking `Member → MemberRole → Role → RolePermission → Permission`. +- JWT only carries `{ sub: memberId, v: tokenVersion }` — **no permissions in the token**. Permissions are stored in an in-memory cache (`@nestjs/cache-manager`) and loaded by `JwtStrategy.validate()` on every request. +- Cache miss fallback: if permissions aren't cached, `JwtStrategy` queries the database and populates the cache. - `POST /auth/dev-login` is available in non-production for quick local testing without GitHub OAuth. -- To mark a route public: use the `@Public()` decorator. To require a permission: use `@Permissions('permission:code')`. -**Permission model (`prisma/models/role.prisma`):** `Member` → `MemberRole` (join) → `Role` → `RolePermission` (join) → `Permission`. Permissions use colon-namespaced codes (e.g., `activity:manage`). Seed creates an `admin` role with all permissions. +**Scoped RBAC (`src/auth/`):** +- Three permission scopes: `PLATFORM` (global), `ORG` (organization-level), `EVENT` (activity-level). +- `AuthService.getScopedPermissions()` returns `{ platform: string[], org: Record, event: Record }`. +- **ORG permissions cascade to all events under that organization** — the guard resolves `activity → organization` in real-time and merges org permissions into the event scope. +- To mark a route public: use the `@Public()` decorator. +- To require permissions in a specific scope: use `@RequirePermissions({ scope: "EVENT", param: "activityId", perms: ["event:edit"] })`. +- Legacy `@Permissions('permission:code')` still works — treated as `PLATFORM` scope. + +**Permission invalidation:** +- Call `AuthService.bumpTokenVersion(memberId)` to revoke all tokens for a user. This increments `tokenVersion`, invalidates the cache, and revokes all refresh tokens. Existing JWTs immediately receive 401. + +**Permission codes (seed):** +- `org:profile`, `org:finance`, `org:member:manage`, `org:report` +- `event:edit`, `event:registration:read`, `event:checkin`, `event:order:manage` +- `activity:manage`, `permission:manage`, `role:manage` (platform) + +**Permission model:** `Member` → `Membership` (join with scope) → `Role` → `RolePermission` (join) → `Permission`. +- `Membership` includes `scopeType` (PLATFORM | ORG | EVENT) and optional `organizationId` / `activityId` FKs. +- Permissions use colon-namespaced codes (e.g., `event:edit`). Seed creates scoped roles: + - PLATFORM: `admin` (all platform permissions) + - ORG: `Owner`, `Admin`, `Accountant` + - EVENT: `Admin`, `Creator`, `Accountant`, `Checkin`, `Streaming` **Prisma setup:** - Schema is split across `prisma/models/*.prisma` files; `prisma.config.ts` points Prisma at the `prisma/` directory. diff --git a/HANDOFF-permission-admin.md b/HANDOFF-permission-admin.md new file mode 100644 index 0000000..f29d44e --- /dev/null +++ b/HANDOFF-permission-admin.md @@ -0,0 +1,74 @@ +# HANDOFF — 權限管理後台(前端) + +> 給接手繼續開發的人。自足文件。 +> 建立:2026-07-25。對照後端 RBAC 交接見 [`HANDOFF.md`](HANDOFF.md)、[`progress.md`](progress.md)、KKTIX 調研見 [`docs/rbac-kktix-research.md`](docs/rbac-kktix-research.md)。 + +## 一句話 + +後端的「參考 KKTIX」scoped RBAC(PLATFORM / ORG / EVENT + Email 邀請)**已完成並有測試**;前端 admin 只做完了**組織層的角色指派**,活動層與邀請 UI 尚未做。這份文件記錄實測到的進度與待辦。 + +--- + +## 目前進度(2026-07-25 實測) + +前端 `web/`(Vite + React + shadcn/ui + react-router),登入用 dev-login(`yale@agent.local` 是唯一有 `permission:manage` 的帳號)。 + +| 頁面 / 分頁 | 後端端點 | 前端 | 實測狀態 | +|---|---|---|---| +| 成員管理 `/members` | `GET/POST/PATCH/DELETE /members` | ✅ | 列表、CRUD 正常 | +| 角色 `/permissions`(預設) | `GET/POST/PATCH/DELETE /roles` | ✅ | 9 個角色 + 權限勾選正常 | +| 權限碼 `/permissions/permissions` | `GET/POST/PATCH/DELETE /permissions` | ✅ | 14 筆權限碼正常 | +| **組織成員** `/permissions/org-members` | `GET /members/organization/:orgId`、`PUT /organizations/:orgId/members/:memberId/roles` | ✅ | **端到端驗過**:建組織→選組織→列成員→管理角色→儲存→DB 確認寫入 | +| **成員** `/permissions/members` | (同組織成員) | ❌ | **placeholder 空殼**(`members-coming-soon.tsx`),且承諾的功能與「組織成員」重複 | +| **活動成員(EVENT scope)** | `GET/PUT /activities/:activityId/members/:memberId/roles`(後端已有) | ❌ | **前端完全沒做** | +| **Email 邀請** | 見下方端點清單(後端完整) | ❌ | **前端完全沒做**(一個畫面都沒有) | + +**判斷**:當「後端 API + 權限引擎」交付可以;當「給人用的 KKTIX-like 權限後台」交付**還不行**,缺活動層 + 邀請兩塊硬功能。 + +--- + +## 待辦 TODO(依優先序) + +### 高 — 補齊 KKTIX 兩層裡缺的活動層 +- [ ] **活動成員角色指派頁**。後端端點已就緒(`GET/PUT /activities/:activityId/members/:memberId/roles`,需 `event:member:manage`)。可直接照 `web/src/pages/permissions/org-members-page.tsx` 的結構複製一份,把 org 換成 activity、角色 catalog 抓 `EVENT` scope(`listRoleCatalog("EVENT")`)。 +- [ ] 活動下拉的資料來源:目前前端沒有活動列表 API 的封裝,需確認 `GET /activities`(或等價端點)並加 `web/src/lib/` 封裝。 + +### 高 — 補齊 KKTIX 招牌的邀請流程 UI +- [ ] **邀請管理頁**(組織 + 活動)。後端端點: + - `GET /organizations/:orgId/invitations`、`POST /organizations/:orgId/invitations`、`POST /organizations/:orgId/invitations/:id/resend` + - `GET /activities/:activityId/invitations`、`POST …`、`POST …/:id/resend` + - `POST /invitations/accept`、`POST /invitations/reject`(受邀者端,帶 token) +- [ ] 邀請狀態機 UI:PENDING / ACCEPTED / EXPIRED(7 天)等狀態呈現,見調研文件第 4 節。 + +### 中 — 清理與收尾 +- [ ] **移除重複的「成員」placeholder 分頁**(`web/src/pages/permissions/members-coming-soon.tsx` + router `permissions/members`)。它承諾的東西「組織成員」已做完,留著只會誤導。 +- [ ] **user-menu 顯示錯誤**:登入後左下角仍顯示「訪客 guest@example.com」,未反映實際登入者。需接 `/auth/me`(或等價)把當前使用者帶進 `user-menu.tsx`。router 註解也提到「route guard can be added once /auth/me is wired up」。 +- [ ] **cascade 產品決策**(見 `HANDOFF.md` 第 49 點):seed 的 ORG `Owner`/`Admin` 只含 `org:*`、不含 event 碼,所以預設 org admin 無法 cascade 管活動。要符合 KKTIX「組織管理員可管旗下活動」需在 ORG 角色加 event 權限碼。這是產品決定,非純工程。 + +--- + +## 已知行為(不是 bug,記下來免得誤判) + +- **改自己的角色會被登出**。改角色 → 後端 `bumpTokenVersion(memberId)`(`src/rbac/membership.service.ts:113`)使舊 JWT 失效 → 前端攔截器 401 → 踢回登入。這是正確的權限即時失效機制;只是自己改自己時體感像閃退。改別人不會。 +- **組織成員頁「你目前不屬於任何組織」**:當登入者不屬於任何組織時的正常空狀態,不是壞掉。建組織後即消失。 + +--- + +## 本機怎麼跑 + +```bash +# 後端(讀本機 dev DB,見下) +cd form-platform-server && pnpm start:dev # :4000 + +# 前端 +cd form-platform-server/web && pnpm dev # :3000(被佔會往上找,本次實測在 :3003) +``` + +- 前端 API base 走**相對路徑 + vite proxy**(`web/.env` 的 `VITE_API_BASE_URL` 已註解掉),所以前端跑在哪個 port 都能連後端,不吃 CORS。若要直打後端絕對網址,必須同步把該 port 加進後端 `.env` 的 `CLIENT_URL`。 +- 登入:`/login` 下半部 dev-login,email 填 `yale@agent.local`(唯一有 `permission:manage`)。GitHub 按鈕目前不能用(`GITHUB_CLIENT_ID` 為空,未設 OAuth app),dev-login 才是可用路徑。 + +## 資料庫 + +- 本機開發已改用 **local `form_platform_dev`**(`DATABASE_URL` 指 localhost;原雲端 Zeabur 那條保留成 `.env` 裡的註解 `# PROD_DATABASE_URL`)。 +- e2e 用 **local `form_platform_test`**(`TEST_DATABASE_URL`),`test/jest-e2e.setup.ts` 會 abort 任何非 localhost 主機——已實測負向測試(缺變數 / 遠端主機都會擋)。 +- dev DB 目前種子:14 permissions + 9 roles + `yale@agent.local`(PLATFORM admin) + 測試組織「COSCUP 測試組織」(yale=Owner+Accountant)。**沒有活動資料**,所以活動層功能要先建 activity 才驗得動。 diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..52a8d29 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,55 @@ +# Handoff — Scoped RBAC + 成員邀請(參考 KKTIX) + +> 日期:2026-06-19 / 分支:`feature/authentication` + +## 背景 +`form-platform-server`(NestJS + Prisma + PostgreSQL,conference CFP 平台)。PM 要求權限系統「參考 KKTIX」。本次把原本的**全域扁平 RBAC** 改造為 **兩層 scope(PLATFORM / ORG / EVENT)的 scoped RBAC + Email 邀請流程**。功能已完成並通過實機驗證。 + +## 設計核心 +1. **scopeType 三層**:`PLATFORM`(平台運營者,相容舊全域角色)、`ORG`、`EVENT`。 +2. **`Membership` 取代 `MemberRole`**:成員資格 = (member, scope, role);scope 用 `scopeType` + 可空的 `organizationId` / `activityId` FK。 +3. **權限 scoped 結構**:`getScopedPermissions()` 回傳 `{ platform: string[], org: Record, event: Record }`,存進記憶體 cache。JWT 仍只帶 `{ sub, v }`(沿用既有 TokenVersion 機制)。 +4. **Guard cascade**:`@RequirePermissions({ scope, param, perms })` 從路由 param 取 scopeId;PLATFORM 全域生效;**ORG 權限向下 cascade 到該 org 旗下所有 EVENT**(guard 即時查 activity→org,並快取 `act_org:`)。 + +## 關鍵檔案 +| 區域 | 檔案 | +|------|------| +| Schema | `prisma/models/{organization,membership,invitation,role,member,activity}.prisma` | +| Migration | `prisma/migrations/20260619162429_add_scoped_rbac_org_invitation/`(已搬遷舊 member_roles → PLATFORM) | +| 權限解析 | `src/auth/auth.service.ts`(`getScopedPermissions`)、`src/auth/types/scoped-permissions.ts` | +| Guard/Decorator | `src/auth/guards/permission.guard.ts`、`src/auth/decorators/scoped-permissions.decorator.ts`(`@RequirePermissions`) | +| Cache | `src/auth/services/permissions-cache.service.ts`(含 `getActivityOrg`/`setActivityOrg`) | +| 管理 API | `src/organization/*`、`src/rbac/membership.{service,controller}.ts`、`src/rbac/role.*` | +| 邀請 | `src/invitation/*`(`invitation.service.ts` 是狀態機;`mail.service.ts` 是 log stub) | +| Seed | `prisma/seed.ts`(scoped 權限碼 + KKTIX 預設角色) | +| 測試 UI | `public/index.html`(serve 在 `/ui`) | +| 調研文件 | `docs/rbac-kktix-research.md` | + +## API 重點 +- `POST /organizations`(任何登入者,建立者**自動成 Owner**)、`GET /organizations`(我的)、`GET/PATCH/DELETE /organizations/:id` +- `PUT|GET /organizations/:orgId/members/:memberId/roles`、`PUT|GET /activities/:activityId/members/:memberId/roles` +- `POST|GET /organizations/:orgId/invitations`、`/activities/:activityId/invitations`、`:id/resend`、`DELETE :id` +- `POST /invitations/accept`、`/invitations/reject`(需登入,email 須與被邀者相符;過期回 **410**) +- `GET /roles/catalog`(**dev-only**,免登入,給測試 UI 載入角色下拉用;prod 會擋) + +## 預設角色(seed) +- ORG:`Owner`、`Admin`(都全 org 權限)、`Accountant`(finance+report) +- EVENT:`Admin`、`Creator`、`Accountant`、`Checkin`、`Streaming` +- PLATFORM:`admin`(相容舊全域) +- 權限碼:`org:profile|finance|member:manage|report`、`event:edit|registration:read|checkin|order:manage|report|venue|member:manage`、`activity:manage|permission:manage|role:manage`(平台) + +## 驗證狀態 +- ✅ `pnpm build` / `pnpm lint`(0 errors,只剩 main.ts 既有 warning)/ `pnpm test`(**61 passed**) +- ✅ `prisma migrate deploy` + `db seed` 通過 +- ✅ 實機煙霧測試:建組織→Owner 權限→邀請→接受→teammate 取得 org 權限,cascade 結構正常;`/roles/catalog`、`/ui` 皆正常 +- ⚠️ **e2e (`test/auth.e2e-spec.ts`) 已改寫但尚未執行** —— 它的 `beforeEach` 會**清空 `DATABASE_URL` 指向的 DB**(目前指向 Zeabur 雲端 dev DB,沒有獨立 test DB),請在可拋棄的 DB 上才跑。 + +## 待辦 / 已知問題 +1. **e2e 未跑** + 沒有獨立 test DB → 需設定 test 專用 `DATABASE_URL` 後再跑 `pnpm test:e2e`。 +2. **Cascade 設計缺口**:seed 的 ORG `Owner`/`Admin` 角色**只含 `org:*` 碼,不含 event 碼**,所以預設情況下 org admin **無法** cascade 去管 event 成員。若要符合 KKTIX「組織管理員可管旗下活動」,需在 ORG 角色加入 event 範圍權限碼(產品決定)。 +3. **`Activity` 建立 API 沒有帶 `organizationId`**(`CreateActivityDto` 未含),目前活動掛到 org 只能靠 Prisma Studio。若要從 API 建立掛 org 的活動,需擴充 activity DTO/service。 +4. **文件漂移**:`CLAUDE.md` / `AGENTS.md` 仍描述舊的「JWT 內嵌 permissions」「全域 RBAC」模型,需更新。 +5. **使用者偏好**:之後**不要再額外寫 unit/e2e 測試碼**,用實機跑過確認功能即可(本次測試碼保留不動)。 + +## 怎麼測(最快) +`pnpm start:dev` → 開 `http://localhost:4000/ui`:dev-login → 建組織 →(org ID / 角色下拉 / token 都會自動帶入)送邀請 → 換帳號登入 → Accept → /me 看 scoped 權限。Assign roles 面板可改某人角色 → 對方舊 token 立即失效(401)。 diff --git a/docs/rbac-kktix-research.md b/docs/rbac-kktix-research.md new file mode 100644 index 0000000..1ea2f09 --- /dev/null +++ b/docs/rbac-kktix-research.md @@ -0,0 +1,100 @@ +# KKTIX 權限系統調研 + +> 目的:PM 要求權限系統「參考 KKTIX」。本文整理 KKTIX 權限系統的作法,並對照本專案現況與導入方向。 +> +> 來源:KKTIX 合作夥伴中心 +> - [瞭解 KKTIX 組織、活動存取權與預設角色](https://help-organizer.kktix.com/hc/zh-tw/articles/20363991149081) +> - [瞭解組織、活動成員邀請流程及管理](https://help-organizer.kktix.com/hc/zh-tw/articles/20364050144153) +> - [管理權限 section](https://help-organizer.kktix.com/hc/zh-tw/sections/20360802371225) + +--- + +## 1. 核心:兩層 Scope + +KKTIX 權限系統最關鍵的設計是**權限分兩個層級**,且兩層的成員身份與角色是**獨立分開授予**的: + +``` +組織 Organization +├── 組織成員 + 組織角色 ← 管理整個組織 +└── 活動 Event ×N + └── 活動成員 + 活動角色 ← 只管這一場活動 +``` + +- 官方原文:「組織及各活動的權限需要分開給予」。 +- 可以只邀某人加入**單一場活動**當管理成員,而不給他整個組織的權限。 +- 也可在新版權限管理中「單獨邀請夥伴加入單一場活動成為活動管理成員」。 + +## 2. 組織層級(Organization) + +### 預設角色 + +| 角色 | 權限 | 可否編輯/刪除 | +|------|------|--------------| +| 擁有者 Owner | 全部權限(建立組織者) | ❌ 不可 | +| 管理者 Admin | 全部權限 | ❌ 不可 | +| 會計 Accountant | 組織財務、活動報表 | ✅ 可 | + +### 組織權限項目 + +- **組織資料** — 編輯組織名稱、簡介等。 +- **組織財務** — 看帳務報表、管理帳務設定、申請撥款、升級組織/活動。 +- **權限管理**(meta 權限)— 邀請其他人/自己加入組織或活動、編輯成員角色、新增角色與角色對應權限。 + - 具此權限者可在「組織主控台 → 權限管理」管理組織成員與各角色的權限設定。 + +## 3. 活動層級(Event) + +### 預設角色 + +管理者、活動建立者、會計、簽到人員、直播製作人。 + +### 活動權限項目(顆粒度較細) + +- 編輯活動、報名人資料、票券訂單、銷售狀況、座位安排、活動地點設定。 +- **簽到** — 此權限**只看得到不完整的報名資料**,專為簽到櫃台設計。 +- **報名人資料 vs 簽到** 刻意拆開:前者可看/匯出完整資料,後者只給簽到用 → 最小權限原則的具體展現。 + +## 4. 成員邀請流程(狀態機) + +``` +輸入 Email → 寄送邀請函 → [PENDING] + ├─ 7 天內接受 → [ACCEPTED] 取得被賦予的角色權限 + ├─ 逾時(>7天) → [EXPIRED] 連結失效,需重發邀請函 + └─ 對方拒絕 → [REJECTED] +``` + +- 邀請有 **7 天時效**,逾時連結失效。 +- 對方可在邀請信中**同意或拒絕**。 +- 成員列表可查**寄送狀態**。 +- 邀請與接受需在「組織主控台 → 權限管理」或「活動主控台 → 活動權限管理」操作。 + +--- + +## 5. 對照本專案現況 + +本專案目前是**單層、全域、扁平**的 RBAC: + +``` +Member → MemberRole → Role → RolePermission → Permission +``` + +| 面向 | KKTIX | 本專案現況 | 差距 | +|------|-------|-----------|------| +| Scope | 組織 + 活動兩層 | 全域單層,角色不分 scope | 🔴 架構級 | +| 角色綁定對象 | 綁在 org / event 上 | `MemberRole` 全域 | 🔴 | +| 預設角色 | Owner/Admin/會計/簽到… 分層 | 只有 `admin` | 🟡 | +| 成員邀請 | Email 邀請 + 7天時效 + 接受/拒絕 | 無邀請流程,直接指派 | 🟡 | +| 權限顆粒度 | 細到「簽到 vs 完整報名資料」 | `activity:manage` 較粗 | 🟡 | + +**核心結論**:「參考 KKTIX」最實質的意思是——把目前的**全域 RBAC** 改成 **scoped RBAC(角色綁定在 organization / event 上)**,並補上 Email 成員邀請流程。 + +--- + +## 6. 導入方向(摘要) + +完整實作計畫見 RBAC scoped 改造的工程 plan。重點: + +1. **scopeType 三層**:`PLATFORM`(平台運營者,相容現況)、`ORG`、`EVENT`。 +2. **Membership 取代 MemberRole**:成員資格 = (誰, 在哪個 scope, 擔任什麼 role)。 +3. **權限改為 scoped 結構**:依 scope 分桶,沿用現有 JWT 最小化 + cache 機制。 +4. **Org 權限向下 cascade 到旗下 Event**(KKTIX 行為)。 +5. **Invitation 模型** 實作 Email 邀請 + 7天時效 + 接受/拒絕狀態機。 diff --git a/eslint.config.mjs b/eslint.config.mjs index 1407eae..a9be77f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -47,4 +47,16 @@ export default tseslint.config( "@typescript-eslint/no-namespace": "off", }, }, + { + // Test files lean on `any` mocks (Prisma fixtures, supertest `res.body`, + // jest mock matchers). The unsafe-* / unbound-method rules add noise here. + files: ["test/**/*.ts", "**/*.spec.ts"], + rules: { + "@typescript-eslint/no-unsafe-call": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", + "@typescript-eslint/unbound-method": "off", + }, + }, ); diff --git a/package.json b/package.json index 67e77e4..b90d79e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,10 @@ "test:watch": "jest --watch", "test:cov": "jest --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", - "test:e2e": "jest --config ./test/jest-e2e.json" + "test:e2e": "NODE_OPTIONS=--experimental-vm-modules jest --config ./test/jest-e2e.json" }, "dependencies": { + "@nestjs/cache-manager": "^3.1.2", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", @@ -30,6 +31,7 @@ "@prisma/adapter-pg": "^7.4.1", "@prisma/client": "^7.4.1", "arctic": "^3.7.0", + "cache-manager": "^7.2.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "cookie-parser": "^1.4.7", @@ -82,6 +84,13 @@ "transform": { "^.+\\.(t|j)s$": "ts-jest" }, + "transformIgnorePatterns": [ + "node_modules/(?!(arctic|passport-jwt)/)" + ], + "moduleNameMapper": { + "^src/(.*)$": "/$1", + "^(.+)\\.js$": "$1" + }, "collectCoverageFrom": [ "**/*.(t|j)s" ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c75134..7934100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@nestjs/cache-manager': + specifier: ^3.1.2 + version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2) '@nestjs/common': specifier: ^11.0.1 version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -38,6 +41,9 @@ importers: arctic: specifier: ^3.7.0 version: 3.7.0 + cache-manager: + specifier: ^7.2.8 + version: 7.2.8 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -355,6 +361,9 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@cacheable/utils@2.4.1': + resolution: {integrity: sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==} + '@chevrotain/cst-dts-gen@10.5.0': resolution: {integrity: sha512-lhmC/FyqQ2o7pGK4Om+hzuDrm9rhFYIJ/AXoQBeongmn870Xeb0L6oGEiuR8nohFNL5sMaQEJWCxr1oIVIVXrw==} @@ -873,6 +882,9 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@lukeed/csprng@1.1.0': resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} @@ -887,6 +899,15 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@nestjs/cache-manager@3.1.2': + resolution: {integrity: sha512-Eglt8lUzC3Q3OZ2hFt4vLZ190M94YSJXUiKo67K/zlUgZQGtvxL0AYeKbG96x8+1gJTF7QhFpYw/RkQ28416Mw==} + peerDependencies: + '@nestjs/common': ^9.0.0 || ^10.0.0 || ^11.0.0 + '@nestjs/core': ^9.0.0 || ^10.0.0 || ^11.0.0 + cache-manager: '>=6' + keyv: '>=5' + rxjs: ^7.8.1 + '@nestjs/cli@11.0.16': resolution: {integrity: sha512-P0H+Vcjki6P5160E5QnMt3Q0X5FTg4PZkP99Ig4lm/4JWqfw32j3EXv3YBTJ2DmxLwOQ/IS9F7dzKpMAgzKTGg==} engines: {node: '>= 20.11'} @@ -1312,6 +1333,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -1352,41 +1374,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -1680,6 +1710,9 @@ packages: magicast: optional: true + cache-manager@7.2.8: + resolution: {integrity: sha512-0HDaDLBBY/maa/LmUVAr70XUOwsiQD+jyzCBjmUErYZUKdMS9dT59PqW59PpVqfGM7ve6H0J6307JTpkCYefHQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2339,6 +2372,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.1: + resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2347,6 +2384,9 @@ packages: resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==} engines: {node: '>=16.9.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2662,6 +2702,9 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -4001,6 +4044,11 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@cacheable/utils@2.4.1': + dependencies: + hashery: 1.5.1 + keyv: 5.6.0 + '@chevrotain/cst-dts-gen@10.5.0': dependencies: '@chevrotain/gast': 10.5.0 @@ -4555,6 +4603,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@keyv/serialize@1.1.1': {} + '@lukeed/csprng@1.1.0': {} '@microsoft/tsdoc@0.16.0': {} @@ -4571,6 +4621,14 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@nestjs/cache-manager@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(cache-manager@7.2.8)(keyv@5.6.0)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + cache-manager: 7.2.8 + keyv: 5.6.0 + rxjs: 7.8.2 + '@nestjs/cli@11.0.16(@types/node@22.19.11)': dependencies: '@angular-devkit/core': 19.2.19(chokidar@4.0.3) @@ -5489,6 +5547,11 @@ snapshots: pkg-types: 2.3.0 rc9: 2.1.2 + cache-manager@7.2.8: + dependencies: + '@cacheable/utils': 2.4.1 + keyv: 5.6.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -6177,12 +6240,18 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.1: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 hono@4.11.4: {} + hookified@1.15.1: {} + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -6674,6 +6743,10 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + leven@3.1.0: {} levn@0.4.1: diff --git a/prisma/migrations/20260607143147_add_member_token_version/migration.sql b/prisma/migrations/20260607143147_add_member_token_version/migration.sql new file mode 100644 index 0000000..f5bf91f --- /dev/null +++ b/prisma/migrations/20260607143147_add_member_token_version/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "members" ADD COLUMN "token_version" INTEGER NOT NULL DEFAULT 1; diff --git a/prisma/migrations/20260614024041_add_refresh_tokens/migration.sql b/prisma/migrations/20260614024041_add_refresh_tokens/migration.sql new file mode 100644 index 0000000..99c1e25 --- /dev/null +++ b/prisma/migrations/20260614024041_add_refresh_tokens/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" UUID NOT NULL, + "member_id" UUID NOT NULL, + "token_hash" VARCHAR(255) NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "replaced_by" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_member_id_idx" ON "refresh_tokens"("member_id"); + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260619162429_add_scoped_rbac_org_invitation/migration.sql b/prisma/migrations/20260619162429_add_scoped_rbac_org_invitation/migration.sql new file mode 100644 index 0000000..be198aa --- /dev/null +++ b/prisma/migrations/20260619162429_add_scoped_rbac_org_invitation/migration.sql @@ -0,0 +1,133 @@ +-- CreateEnum +CREATE TYPE "InvitationStatus" AS ENUM ('PENDING', 'ACCEPTED', 'REJECTED', 'EXPIRED'); + +-- CreateEnum +CREATE TYPE "RoleScope" AS ENUM ('PLATFORM', 'ORG', 'EVENT'); + +-- AlterTable +ALTER TABLE "activities" ADD COLUMN "organization_id" UUID; + +-- AlterTable +ALTER TABLE "roles" ADD COLUMN "scope" "RoleScope" NOT NULL DEFAULT 'PLATFORM'; + +-- DropIndex +DROP INDEX "roles_name_key"; + +-- CreateTable +CREATE TABLE "organizations" ( + "id" UUID NOT NULL, + "name" VARCHAR(255) NOT NULL, + "slug" VARCHAR(255) NOT NULL, + "description" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "organizations_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "memberships" ( + "id" UUID NOT NULL, + "member_id" UUID NOT NULL, + "role_id" UUID NOT NULL, + "scope_type" "RoleScope" NOT NULL, + "organization_id" UUID, + "activity_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "memberships_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invitations" ( + "id" UUID NOT NULL, + "email" VARCHAR(255) NOT NULL, + "token" VARCHAR(255) NOT NULL, + "status" "InvitationStatus" NOT NULL DEFAULT 'PENDING', + "scope_type" "RoleScope" NOT NULL, + "organization_id" UUID, + "activity_id" UUID, + "role_id" UUID NOT NULL, + "invited_by_id" UUID NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "responded_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invitations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "organizations_slug_key" ON "organizations"("slug"); + +-- CreateIndex +CREATE INDEX "memberships_member_id_idx" ON "memberships"("member_id"); + +-- CreateIndex +CREATE INDEX "memberships_organization_id_idx" ON "memberships"("organization_id"); + +-- CreateIndex +CREATE INDEX "memberships_activity_id_idx" ON "memberships"("activity_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "memberships_member_id_role_id_scope_type_organization_id_ac_key" ON "memberships"("member_id", "role_id", "scope_type", "organization_id", "activity_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "invitations_token_key" ON "invitations"("token"); + +-- CreateIndex +CREATE INDEX "invitations_email_idx" ON "invitations"("email"); + +-- CreateIndex +CREATE INDEX "invitations_organization_id_idx" ON "invitations"("organization_id"); + +-- CreateIndex +CREATE INDEX "invitations_activity_id_idx" ON "invitations"("activity_id"); + +-- CreateIndex +CREATE INDEX "activities_organization_id_idx" ON "activities"("organization_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "roles_name_scope_key" ON "roles"("name", "scope"); + +-- AddForeignKey +ALTER TABLE "activities" ADD CONSTRAINT "activities_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_member_id_fkey" FOREIGN KEY ("member_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "memberships" ADD CONSTRAINT "memberships_activity_id_fkey" FOREIGN KEY ("activity_id") REFERENCES "activities"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_organization_id_fkey" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_activity_id_fkey" FOREIGN KEY ("activity_id") REFERENCES "activities"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_role_id_fkey" FOREIGN KEY ("role_id") REFERENCES "roles"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "invitations" ADD CONSTRAINT "invitations_invited_by_id_fkey" FOREIGN KEY ("invited_by_id") REFERENCES "members"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Data migration: carry existing global member_roles over as PLATFORM-scoped memberships +-- (so current platform admins keep their access after the cutover). +INSERT INTO "memberships" ("id", "member_id", "role_id", "scope_type", "created_at", "updated_at") +SELECT gen_random_uuid(), "member_id", "role_id", 'PLATFORM', "created_at", "updated_at" +FROM "member_roles"; + +-- DropForeignKey +ALTER TABLE "member_roles" DROP CONSTRAINT "member_roles_member_id_fkey"; + +-- DropForeignKey +ALTER TABLE "member_roles" DROP CONSTRAINT "member_roles_role_id_fkey"; + +-- DropTable +DROP TABLE "member_roles"; diff --git a/prisma/models/activity.prisma b/prisma/models/activity.prisma index 7d0e7db..21877fc 100644 --- a/prisma/models/activity.prisma +++ b/prisma/models/activity.prisma @@ -2,6 +2,7 @@ model Activity { id String @id @db.Uuid name String @db.VarChar(255) slug String @unique @db.VarChar(255) // URL path segment, e.g., 'jsdc-2026' + organizationId String? @map("organization_id") @db.Uuid startAt DateTime @map("start_at") endAt DateTime @map("end_at") supportedLanguages String[] @map("supported_languages") @@ -9,8 +10,12 @@ model Activity { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - contents ActivityContent[] + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + contents ActivityContent[] + memberships Membership[] + invitations Invitation[] + @@index([organizationId]) @@map("activities") } diff --git a/prisma/models/invitation.prisma b/prisma/models/invitation.prisma new file mode 100644 index 0000000..47bdbfb --- /dev/null +++ b/prisma/models/invitation.prisma @@ -0,0 +1,33 @@ +enum InvitationStatus { + PENDING + ACCEPTED + REJECTED + EXPIRED +} + +/// Email invitation to join a scope (org/event) with a given role. +/// Mirrors KKTIX: 7-day expiry, accept/reject, status tracked for the member list. +model Invitation { + id String @id @db.Uuid + email String @db.VarChar(255) + token String @unique @db.VarChar(255) + status InvitationStatus @default(PENDING) + scopeType RoleScope @map("scope_type") + organizationId String? @map("organization_id") @db.Uuid + activityId String? @map("activity_id") @db.Uuid + roleId String @map("role_id") @db.Uuid + invitedById String @map("invited_by_id") @db.Uuid + expiresAt DateTime @map("expires_at") + respondedAt DateTime? @map("responded_at") + createdAt DateTime @default(now()) @map("created_at") + + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + activity Activity? @relation(fields: [activityId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + invitedBy Member @relation("InvitationsSent", fields: [invitedById], references: [id], onDelete: Cascade) + + @@index([email]) + @@index([organizationId]) + @@index([activityId]) + @@map("invitations") +} diff --git a/prisma/models/member.prisma b/prisma/models/member.prisma index c4033e7..8f6cd4a 100644 --- a/prisma/models/member.prisma +++ b/prisma/models/member.prisma @@ -13,12 +13,15 @@ model Member { jobTitle String? @map("job_title") @db.VarChar(100) avatarUrl String? @map("avatar_url") @db.Text status MemberStatus @default(ACTIVE) + tokenVersion Int @default(1) @map("token_version") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - providers MemberProvider[] - memberLinks MemberLink[] - memberRoles MemberRole[] + providers MemberProvider[] + memberLinks MemberLink[] + memberships Membership[] + refreshTokens RefreshToken[] + invitationsSent Invitation[] @relation("InvitationsSent") @@map("members") } @@ -50,15 +53,3 @@ model MemberLink { @@map("member_links") } -model MemberRole { - memberId String @map("member_id") @db.Uuid - roleId String @map("role_id") @db.Uuid - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") - - member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) - role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) - - @@id([memberId, roleId]) - @@map("member_roles") -} diff --git a/prisma/models/membership.prisma b/prisma/models/membership.prisma new file mode 100644 index 0000000..a0afd5e --- /dev/null +++ b/prisma/models/membership.prisma @@ -0,0 +1,26 @@ +/// A scoped role binding: a member holds `role` within a given scope. +/// - PLATFORM: organizationId & activityId both null (platform operators) +/// - ORG: organizationId set, activityId null +/// - EVENT: activityId set, organizationId null +/// Exactly which FK is set must match `scopeType` (enforced in the service layer). +model Membership { + id String @id @db.Uuid + memberId String @map("member_id") @db.Uuid + roleId String @map("role_id") @db.Uuid + scopeType RoleScope @map("scope_type") + organizationId String? @map("organization_id") @db.Uuid + activityId String? @map("activity_id") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) + role Role @relation(fields: [roleId], references: [id], onDelete: Cascade) + organization Organization? @relation(fields: [organizationId], references: [id], onDelete: Cascade) + activity Activity? @relation(fields: [activityId], references: [id], onDelete: Cascade) + + @@unique([memberId, roleId, scopeType, organizationId, activityId]) + @@index([memberId]) + @@index([organizationId]) + @@index([activityId]) + @@map("memberships") +} diff --git a/prisma/models/organization.prisma b/prisma/models/organization.prisma new file mode 100644 index 0000000..585268d --- /dev/null +++ b/prisma/models/organization.prisma @@ -0,0 +1,14 @@ +model Organization { + id String @id @db.Uuid + name String @db.VarChar(255) + slug String @unique @db.VarChar(255) // URL path segment, e.g., 'jsdc' + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + activities Activity[] + memberships Membership[] + invitations Invitation[] + + @@map("organizations") +} diff --git a/prisma/models/refresh-token.prisma b/prisma/models/refresh-token.prisma new file mode 100644 index 0000000..177e862 --- /dev/null +++ b/prisma/models/refresh-token.prisma @@ -0,0 +1,14 @@ +model RefreshToken { + id String @id @db.Uuid + memberId String @map("member_id") @db.Uuid + tokenHash String @unique @map("token_hash") @db.VarChar(255) + expiresAt DateTime @map("expires_at") + revokedAt DateTime? @map("revoked_at") + replacedBy String? @map("replaced_by") @db.Uuid + createdAt DateTime @default(now()) @map("created_at") + + member Member @relation(fields: [memberId], references: [id], onDelete: Cascade) + + @@index([memberId]) + @@map("refresh_tokens") +} diff --git a/prisma/models/role.prisma b/prisma/models/role.prisma index 5544bf6..3cd0066 100644 --- a/prisma/models/role.prisma +++ b/prisma/models/role.prisma @@ -1,13 +1,22 @@ -model Role { - id String @id @db.Uuid - name String @unique @db.VarChar(50) - description String? @db.VarChar(255) - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @updatedAt @map("updated_at") +enum RoleScope { + PLATFORM + ORG + EVENT +} - members MemberRole[] +model Role { + id String @id @db.Uuid + name String @db.VarChar(50) + scope RoleScope @default(PLATFORM) + description String? @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + memberships Membership[] permissions RolePermission[] + invitations Invitation[] + @@unique([name, scope]) @@map("roles") } diff --git a/prisma/seed.ts b/prisma/seed.ts index c5b5887..821f1af 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,4 +1,5 @@ import { PrismaClient } from "../generated/prisma/client"; +import { RoleScope } from "../generated/prisma/enums"; import { PrismaPg } from "@prisma/adapter-pg"; import { Pool } from "pg"; import { withId } from "src/common/utils/db.util"; @@ -9,40 +10,147 @@ const pool = new Pool({ const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); -async function main() { - const permissionCodes = ["activity:manage"]; +// --- Scoped permission catalogue (KKTIX-style) --- +const PLATFORM_PERMISSIONS = [ + "activity:manage", + "permission:manage", + "role:manage", +]; + +const ORG_PERMISSIONS = [ + "org:profile", // edit organization info + "org:finance", // financial reports & settings + "org:member:manage", // invite members, edit roles (KKTIX「權限管理」) + "org:report", // activity reports +]; + +const EVENT_PERMISSIONS = [ + "event:edit", // edit the activity + "event:registration:read", // view/export full registration data + "event:checkin", // check-in only (limited registration view) + "event:order:manage", // ticket orders + "event:report", // sales status / reports + "event:venue", // venue & seating + "event:member:manage", // invite activity members, edit roles +]; + +const ALL_PERMISSION_CODES = [ + ...PLATFORM_PERMISSIONS, + ...ORG_PERMISSIONS, + ...EVENT_PERMISSIONS, +]; + +// --- Default roles per scope (KKTIX presets) --- +interface RoleSeed { + name: string; + scope: RoleScope; + description: string; + permissions: string[]; +} + +const ROLE_SEEDS: RoleSeed[] = [ + { + name: "admin", + scope: RoleScope.PLATFORM, + description: "Platform administrator", + permissions: PLATFORM_PERMISSIONS, + }, + { + name: "Owner", + scope: RoleScope.ORG, + description: "Organization owner (all org permissions)", + permissions: ORG_PERMISSIONS, + }, + { + name: "Admin", + scope: RoleScope.ORG, + description: "Organization admin (all org permissions)", + permissions: ORG_PERMISSIONS, + }, + { + name: "Accountant", + scope: RoleScope.ORG, + description: "Organization accountant (finance & reports)", + permissions: ["org:finance", "org:report"], + }, + { + name: "Admin", + scope: RoleScope.EVENT, + description: "Activity admin (all activity permissions)", + permissions: EVENT_PERMISSIONS, + }, + { + name: "Creator", + scope: RoleScope.EVENT, + description: "Activity creator", + permissions: [ + "event:edit", + "event:registration:read", + "event:order:manage", + "event:report", + "event:venue", + ], + }, + { + name: "Accountant", + scope: RoleScope.EVENT, + description: "Activity accountant (orders & reports)", + permissions: ["event:order:manage", "event:report"], + }, + { + name: "Checkin", + scope: RoleScope.EVENT, + description: "Check-in staff (limited registration view)", + permissions: ["event:checkin"], + }, + { + name: "Streaming", + scope: RoleScope.EVENT, + description: "Streaming producer", + permissions: ["event:edit"], + }, +]; +async function main() { + // 1. Permissions const permissions = await Promise.all( - permissionCodes.map((code) => + ALL_PERMISSION_CODES.map((code) => prisma.permission.upsert({ where: { code }, update: {}, create: withId({ code, - description: `${code.replace(":", " ")} permission`, + description: `${code.replace(/:/g, " ")} permission`, }), }), ), ); + const byCode = new Map(permissions.map((p) => [p.code, p.id])); + console.log(`Seeded ${permissions.length} permissions.`); - console.log(`Created ${permissions.length} permissions.`); - - // Create admin role with all permissions - await prisma.role.upsert({ - where: { name: "admin" }, - update: {}, - create: withId({ - name: "admin", - description: "admin", - permissions: { - create: permissions.map((p) => ({ - permission_id: p.id, - })), - }, - }), - }); + // 2. Roles (+ sync their permissions) + for (const seed of ROLE_SEEDS) { + const role = await prisma.role.upsert({ + where: { name_scope: { name: seed.name, scope: seed.scope } }, + update: { description: seed.description }, + create: withId({ + name: seed.name, + scope: seed.scope, + description: seed.description, + }), + }); - console.log("Admin role seeded with all permissions."); + await prisma.rolePermission.deleteMany({ where: { role_id: role.id } }); + await prisma.rolePermission.createMany({ + data: seed.permissions.map((code) => ({ + role_id: role.id, + permission_id: byCode.get(code)!, + })), + }); + console.log( + `Seeded role ${seed.scope}/${seed.name} with ${seed.permissions.length} permissions.`, + ); + } console.log("Seed data created successfully!"); } diff --git a/progress.md b/progress.md new file mode 100644 index 0000000..8471f8a --- /dev/null +++ b/progress.md @@ -0,0 +1,100 @@ +# Scoped RBAC(參考 KKTIX)實作進度 + +> **2026-06-19 更新**:在既有 TokenVersion + Cache 機制上,新增「參考 KKTIX」的兩層 scope RBAC(PLATFORM / ORG / EVENT)+ Email 成員邀請流程。 +> +> - 調研文件:[docs/rbac-kktix-research.md](docs/rbac-kktix-research.md) +> - 設計與計畫:見 plan 檔 +> +> **本次完成**: +> 1. **Schema/Migration** — 新增 `Organization`、`Membership`(取代全域 `MemberRole`,帶 scopeType + org/activity FK)、`Invitation`;`Role.scope`、`Activity.organizationId`;migration 已搬遷既有 member_roles → PLATFORM。 +> 2. **權限解析 scoped** — `AuthService.getScopedPermissions` 回傳 `{platform, org, event}`,存入 cache;JWT 仍只帶 `{sub, v}`。 +> 3. **Guard + `@RequirePermissions`** — 依路由 param 解析 scopeId,PLATFORM 全域生效,**org 權限向下 cascade 到旗下 event**(即時查 activity→org,含快取)。 +> 4. **管理 API** — `Organization` CRUD(建立者自動成為 Owner)、scoped 成員角色管理(`/organizations|activities/:id/members/:memberId/roles`)。 +> 5. **邀請流程** — `Invitation` service/controller:invite / accept / reject / resend / revoke,7 天時效、Email 比對、`MailService` stub。 +> 6. **Seed** — scoped 權限碼 + KKTIX 預設角色(ORG: Owner/Admin/Accountant;EVENT: Admin/Creator/Accountant/Checkin/Streaming)。 +> 7. **驗證** — `pnpm build` / `pnpm lint`(0 errors)/ `pnpm test`(**61 passed**);`npx prisma migrate deploy` + `db seed` 通過;本機 runtime 煙霧測試:建立組織→Owner 權限→邀請→接受→teammate 取得 org-scoped 權限,全數通過。 +> - e2e(`test/auth.e2e-spec.ts`)已改寫為 scoped 流程,**尚未執行**(會清空 DATABASE_URL 指向的 DB,請在可拋棄的 DB 上跑)。 + +--- + +# TokenVersion + Permissions Cache 實作進度(前一階段) + +## 目前狀態 + +**分支**:`feature/authentication` +**Plan 狀態**:✅ 已通過審核,已拆為 6 個 Task +**執行範圍**:Phase 1 ~ Phase 7(**全部完成**) + +--- + +## Task 清單 + +| # | Phase | 狀態 | 說明 | +|---|-------|------|------| +| 1 | Phase 1: Add tokenVersion to schema and migrate | ✅ `completed` | `prisma/models/member.prisma` 已新增 `tokenVersion Int @default(1)`;migration `20260607143147` 已執行;Prisma client 已重新生成 | +| 2 | Phase 2: Setup cache infrastructure | ✅ `completed` | 安裝 `@nestjs/cache-manager` + `cache-manager`;`AppModule` 已註冊 `CacheModule`;新建 `src/auth/services/permissions-cache.service.ts` | +| 3 | Phase 3: Update auth service login flow | ✅ `completed` | `auth.service.ts`:JWT payload 改為 `{ sub, v }`;登入時把 permissions / tokenVersion 寫入 cache;新增 `bumpTokenVersion()` / `invalidateUserCache()` | +| 4 | Phase 4: Update JwtStrategy | ✅ `completed` | `JwtPayloadToken` 移除 `permissions`;`validate()` 注入 `PermissionsCacheService` + `PrismaService`;驗證 tokenVersion 並從 cache 讀 permissions | +| 5 | Phase 5: Simplify PermissionGuard | ✅ `completed` | `PermissionGuard` 直接比對 `req.user.permissions`(由 JwtStrategy 預先從 cache 載入) | +| 6 | Phase 7: Adjust seed and verify | ✅ `completed` | Seed 無需改動(不直接創建 member);手動測試通過:舊 token bump 後回 401,重新登入後新 token 正常 | + +--- + +## 單元測試(本次新增) + +| 測試檔案 | 測試內容 | 狀態 | +|---------|---------|------| +| `src/auth/services/permissions-cache.service.spec.ts` | set/get/invalidate permissions 與 tokenVersion | ✅ 7 tests pass | +| `src/auth/strategies/jwt.strategy.spec.ts` | validate:cache hit、cache miss fallback、tokenVersion 不匹配、用戶不存在 | ✅ 6 tests pass | +| `src/auth/guards/permission.guard.spec.ts` | 無權限要求、未認證、有權限、缺權限 | ✅ 5 tests pass | +| `src/auth/auth.service.spec.ts` | getUserPermissions、devLogin、bumpTokenVersion、invalidateUserCache | ✅ 8 tests pass | + +**總計:26 tests pass / 26 total** + +--- + +## Git 工作區狀態 + +``` +On branch feature/authentication +尚未提交的變更(本次測試相關): + modified: package.json + new file: src/auth/services/permissions-cache.service.spec.ts + new file: src/auth/strategies/jwt.strategy.spec.ts + new file: src/auth/guards/permission.guard.spec.ts + new file: src/auth/auth.service.spec.ts +``` + +--- + +## 設計摘要 + +將登入機制從「JWT 內嵌 permissions 字串陣列」改為 **TokenVersion + In-Memory Cache**: + +1. **JWT 最小化**:只帶 `sub`(memberId)+ `v`(tokenVersion),不再內嵌 email 與 permissions。 +2. **Permissions 走 Cache**:登入時把 `permissions` 與 `tokenVersion` 寫入 `@nestjs/cache-manager` 記憶體快取。 +3. **每次請求驗證**:`JwtStrategy.validate()` 從快取讀 `tokenVersion` 比對 JWT 的 `v`,不匹配則 `throw UnauthorizedException('Token has been revoked')`;permissions cache miss 時 fallback 查 DB 並回填快取。 +4. **即時失效**:管理後台變更用戶角色後呼叫 `AuthService.bumpTokenVersion(memberId)`,會自動 `++tokenVersion` 並 invalidate cache,舊 JWT 立即失效(401)。 + +--- + +## 品質檢查結果 + +| 檢查項目 | 結果 | +|---------|------| +| `pnpm build` | ✅ 通過 | +| `pnpm lint` | ✅ 通過 | +| `pnpm test` | ✅ **26 passed / 26 total** | +| `npx prisma db seed` | ✅ 通過 | +| **手動 E2E 測試** | ✅ 通過 | + +### E2E 測試記錄 + +| 步驟 | 預期 | 實際 | +|------|------|------| +| dev-login 取得 token | 拿到 `v=1` 的 JWT | ✅ | +| 帶 token 訪問受保護路由 | 403(token 有效但缺權限) | ✅ | +| 呼叫 bumpTokenVersion | tokenVersion 變為 2 | ✅ | +| 帶舊 token 再次訪問 | 401(token revoked) | ✅ | +| 重新登入取得新 token | 拿到 `v=2` 的 JWT | ✅ | +| 帶新 token 訪問 | 403(token 有效) | ✅ | diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..cc67a23 --- /dev/null +++ b/public/index.html @@ -0,0 +1,513 @@ + + + + + Auth Mock UI + + + + +

Auth Mock UI — form-platform-server

+ +
+
+

Auth lifecycle

+
+ + +
+
+ + + + +
+
Visible cookies:
+
httpOnly cookies (access_token, refresh_token) won't appear above — by design.
+
No calls yet.
+
+ +
+

Current /me snapshot

+
+ +
+
Click "Re-check /me" or run any call above.
+
Permissions:
+
+
+ +
+

RBAC admin

+
+ Requires permission:manage and role:manage. + Bootstrap admin: run npx prisma studio, find your member, attach the admin role + (created by npx prisma db seed), then click Refresh above to pick up the new perms. +
+ +
+
+

Permissions

+
+ + + +
+
+
+ +
+

Roles

+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ +
+

Organizations & scoped membership (KKTIX-style)

+
+ Tip: create an org → you auto-become its Owner. The org id then auto-fills + the Assign / Invite panels, role dropdowns load from the seed, and a sent invite + auto-fills the Accept token. No UUID copy-pasting needed. + +
+
+
+

Organizations

+
+ + + +
+
+ +
+
+
+ +
+

Assign roles in a scope

+
+ + +
+
+ + +
+
+ + +
+
+ After replace, target's access cookie is immediately revoked + (tokenVersion bumped). Org roles cascade down to that org's activities. +
+
+
+ +
+

Invitations

+
+ + + + + + +
+
+ + + +
+
+ Invites expire after 7 days. Accept/Reject use the token and require the + logged-in user's email to match the invitee. +
+
+
+
+
+
+ + + + + + diff --git a/src/activity/activity.service.spec.ts b/src/activity/activity.service.spec.ts new file mode 100644 index 0000000..8f6d36e --- /dev/null +++ b/src/activity/activity.service.spec.ts @@ -0,0 +1,81 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { NotFoundException } from "@nestjs/common"; +import { ActivityService } from "./activity.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateActivityDto } from "./dto/activity.dto"; + +describe("ActivityService", () => { + let service: ActivityService; + let prisma: any; + + const baseDto: CreateActivityDto = { + organizationId: "org-1", + name: "JSDC 2026", + slug: "jsdc-2026", + startAt: new Date("2026-01-02T00:00:00Z"), + endAt: new Date("2026-01-03T00:00:00Z"), + supportedLanguages: ["zh-tw"], + contents: [{ lang: "zh-tw", title: "JSDC" }], + }; + + beforeEach(async () => { + prisma = { + activity: { + findUnique: jest.fn(), + create: jest.fn(), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ActivityService, + { provide: PrismaService, useValue: prisma }, + ], + }).compile(); + + service = module.get(ActivityService); + }); + + describe("create", () => { + it("passes organizationId through to prisma.activity.create", async () => { + // No existing slug + prisma.activity.findUnique.mockResolvedValue(null); + prisma.activity.create.mockResolvedValue({ id: "act-1" }); + + await service.create(baseDto); + + const createArg = prisma.activity.create.mock.calls[0][0]; + expect(createArg.data).toEqual( + expect.objectContaining({ organizationId: "org-1" }), + ); + }); + }); + + describe("findOneById", () => { + it("returns the activity including its organizationId", async () => { + const activity = { + id: "act-1", + organizationId: "org-1", + slug: "jsdc-2026", + contents: [], + }; + prisma.activity.findUnique.mockResolvedValue(activity); + + const result = await service.findOneById("act-1"); + + expect(prisma.activity.findUnique).toHaveBeenCalledWith({ + where: { id: "act-1" }, + include: { contents: true }, + }); + expect(result.organizationId).toBe("org-1"); + }); + + it("throws NotFoundException when the activity does not exist", async () => { + prisma.activity.findUnique.mockResolvedValue(null); + + await expect(service.findOneById("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + }); +}); diff --git a/src/activity/activity.service.ts b/src/activity/activity.service.ts index 163aa43..e32ddea 100644 --- a/src/activity/activity.service.ts +++ b/src/activity/activity.service.ts @@ -30,6 +30,7 @@ export class ActivityService { data: withId({ name: dto.name, slug: dto.slug, + organizationId: dto.organizationId, startAt: dto.startAt, endAt: dto.endAt, closedAt: dto.closedAt || null, diff --git a/src/activity/dto/activity.dto.spec.ts b/src/activity/dto/activity.dto.spec.ts new file mode 100644 index 0000000..a95b1f1 --- /dev/null +++ b/src/activity/dto/activity.dto.spec.ts @@ -0,0 +1,135 @@ +import { validate } from "class-validator"; +import { plainToInstance } from "class-transformer"; +import { ArgumentMetadata, BadRequestException, ValidationPipe } from "@nestjs/common"; +import { CreateActivityDto } from "./activity.dto"; + +const validPayload = () => ({ + organizationId: "550e8400-e29b-41d4-a716-446655440000", + name: "test", + slug: "test-slug", + startAt: new Date(), + endAt: new Date(Date.now() + 86400000), + supportedLanguages: ["zh-tw"], + contents: [{ lang: "zh-tw", title: "test" }], +}); + +describe("CreateActivityDto validation", () => { + describe("class-validator (validate)", () => { + describe("organizationId UUID validation", () => { + it('rejects organizationId "abc123" with the isUUID rule', async () => { + const dto = plainToInstance(CreateActivityDto, { + ...validPayload(), + organizationId: "abc123", + }); + + const errors = await validate(dto); + + const orgIdError = errors.find((e) => e.property === "organizationId"); + expect(orgIdError?.constraints).toMatchObject({ + isUuid: expect.any(String), + }); + }); + + it('rejects organizationId "not-a-uuid" with the isUUID rule', async () => { + const dto = plainToInstance(CreateActivityDto, { + ...validPayload(), + organizationId: "not-a-uuid", + }); + + const errors = await validate(dto); + + const orgIdError = errors.find((e) => e.property === "organizationId"); + expect(orgIdError?.constraints).toMatchObject({ + isUuid: expect.any(String), + }); + }); + + it("accepts a valid UUID organizationId", async () => { + const dto = plainToInstance(CreateActivityDto, { + ...validPayload(), + organizationId: "550e8400-e29b-41d4-a716-446655440000", + }); + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + }); + }); + + it("rejects a payload missing organizationId", async () => { + const { organizationId, ...withoutOrgId } = validPayload(); + const dto = plainToInstance(CreateActivityDto, withoutOrgId); + + const errors = await validate(dto); + + const orgIdError = errors.find((e) => e.property === "organizationId"); + expect(orgIdError).toBeDefined(); + expect(orgIdError?.constraints).toMatchObject({ + isNotEmpty: expect.any(String), + }); + }); + + it("accepts a fully-valid payload", async () => { + const dto = plainToInstance(CreateActivityDto, validPayload()); + + const errors = await validate(dto); + + expect(errors).toHaveLength(0); + }); + }); + + describe("NestJS ValidationPipe", () => { + const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const metadata: ArgumentMetadata = { + type: "body", + metatype: CreateActivityDto, + data: "", + }; + + describe("organizationId UUID validation", () => { + it('rejects organizationId "abc123" with BadRequestException', async () => { + await expect( + pipe.transform( + { ...validPayload(), organizationId: "abc123" }, + metadata, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('rejects organizationId "not-a-uuid" with BadRequestException', async () => { + await expect( + pipe.transform( + { ...validPayload(), organizationId: "not-a-uuid" }, + metadata, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("accepts a valid UUID organizationId", async () => { + await expect( + pipe.transform( + { + ...validPayload(), + organizationId: "550e8400-e29b-41d4-a716-446655440000", + }, + metadata, + ), + ).resolves.toBeInstanceOf(CreateActivityDto); + }); + }); + + it("throws 400 (BadRequestException) when organizationId is missing", async () => { + const { organizationId, ...withoutOrgId } = validPayload(); + + await expect(pipe.transform(withoutOrgId, metadata)).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("passes a fully-valid payload through", async () => { + await expect( + pipe.transform(validPayload(), metadata), + ).resolves.toBeInstanceOf(CreateActivityDto); + }); + }); +}); diff --git a/src/activity/dto/activity.dto.ts b/src/activity/dto/activity.dto.ts index b1cc766..7cb4725 100644 --- a/src/activity/dto/activity.dto.ts +++ b/src/activity/dto/activity.dto.ts @@ -12,6 +12,7 @@ import { MaxLength, IsNotEmpty, ValidateNested, + IsUUID, } from "class-validator"; export class ActivityContentDto { @@ -37,6 +38,11 @@ export class ActivityContentDto { } export class CreateActivityDto { + @ApiProperty({ description: "Organization ID" }) + @IsUUID() + @IsNotEmpty() + organizationId: string; + @ApiProperty() @IsString() @IsNotEmpty() diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts index d22f389..f643a70 100644 --- a/src/app.controller.spec.ts +++ b/src/app.controller.spec.ts @@ -1,8 +1,8 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { AppService } from './app.service'; +import { Test, TestingModule } from "@nestjs/testing"; +import { AppController } from "./app.controller"; +import { AppService } from "./app.service"; -describe('AppController', () => { +describe("AppController", () => { let appController: AppController; beforeEach(async () => { @@ -14,9 +14,9 @@ describe('AppController', () => { appController = app.get(AppController); }); - describe('root', () => { + describe("root", () => { it('should return "Hello World!"', () => { - expect(appController.getHello()).toBe('Hello World!'); + expect(appController.getHello()).toBe("Hello World!"); }); }); }); diff --git a/src/app.module.ts b/src/app.module.ts index 7139152..2ba728c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,9 +1,14 @@ import { Module } from "@nestjs/common"; +import { CacheModule } from "@nestjs/cache-manager"; import { AppController } from "./app.controller"; import { AppService } from "./app.service"; import { PrismaModule } from "src/prisma/prisma.module"; import { AuthModule } from "./auth/auth.module"; import { ActivityModule } from "./activity/activity.module"; +import { RbacModule } from "./rbac/rbac.module"; +import { OrganizationModule } from "./organization/organization.module"; +import { InvitationModule } from "./invitation/invitation.module"; +import { MemberModule } from "./member/member.module"; import { ConfigModule } from "@nestjs/config"; @Module({ @@ -12,9 +17,16 @@ import { ConfigModule } from "@nestjs/config"; isGlobal: true, envFilePath: ".env", }), + CacheModule.register({ + isGlobal: true, + }), PrismaModule, AuthModule, ActivityModule, + RbacModule, + OrganizationModule, + InvitationModule, + MemberModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index a196ba0..0222e9d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,12 +1,67 @@ -import { Controller, Get, Query, Res, Req, Post, Body } from "@nestjs/common"; +import { + Controller, + Get, + Query, + Res, + Req, + Post, + Body, + UnauthorizedException, +} from "@nestjs/common"; import { AuthService } from "./auth.service"; import type { Response, Request } from "express"; import { generateState } from "arctic"; import { Public } from "./decorators/public.decorator"; +import { + RefreshTokenService, + RefreshTokenReuseError, +} from "./services/refresh-token.service"; +import { PrismaService } from "../prisma/prisma.service"; +import type { AuthUser } from "./strategies/jwt.strategy"; + +const ACCESS_COOKIE = "access_token"; +const REFRESH_COOKIE = "refresh_token"; +const ACCESS_TTL_MS = 15 * 60 * 1000; // 15m (matches JWT expiry) +const REFRESH_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7d +const REFRESH_COOKIE_PATH = "/api/v1/auth"; + +function isProd() { + return process.env.NODE_ENV === "production"; +} + +function setAuthCookies( + res: Response, + accessToken: string, + refreshToken: string, +) { + res.cookie(ACCESS_COOKIE, accessToken, { + httpOnly: true, + secure: isProd(), + sameSite: "lax", + maxAge: ACCESS_TTL_MS, + path: "/", + }); + res.cookie(REFRESH_COOKIE, refreshToken, { + httpOnly: true, + secure: isProd(), + sameSite: "lax", + maxAge: REFRESH_TTL_MS, + path: REFRESH_COOKIE_PATH, + }); +} + +function clearAuthCookies(res: Response) { + res.clearCookie(ACCESS_COOKIE, { path: "/" }); + res.clearCookie(REFRESH_COOKIE, { path: REFRESH_COOKIE_PATH }); +} @Controller("auth") export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor( + private readonly authService: AuthService, + private readonly refreshTokenService: RefreshTokenService, + private readonly prisma: PrismaService, + ) {} @Public() @Get("github") @@ -16,7 +71,7 @@ export class AuthController { res.cookie("github_oauth_state", state, { httpOnly: true, - secure: process.env.NODE_ENV === "production", + secure: isProd(), maxAge: 600000, path: "/", }); @@ -42,15 +97,10 @@ export class AuthController { res.clearCookie("github_oauth_state"); try { - const { access_token } = await this.authService.loginWithGithub(code); + const { access_token, refresh_token } = + await this.authService.loginWithGithub(code); - res.cookie("access_token", access_token, { - httpOnly: true, - secure: process.env.NODE_ENV === "production", - sameSite: "lax", - maxAge: 86400000, - path: "/", - }); + setAuthCookies(res, access_token, refresh_token); return res.send(` @@ -81,7 +131,70 @@ export class AuthController { @Public() @Post("dev-login") - async devLogin(@Body() body: { email: string }) { - return this.authService.devLogin(body.email); + async devLogin(@Body() body: { email: string }, @Res() res: Response) { + const result = await this.authService.devLogin(body.email); + setAuthCookies(res, result.access_token, result.refresh_token); + return res.json({ + access_token: result.access_token, + user: result.user, + }); + } + + @Public() + @Post("refresh") + async refresh(@Req() req: Request, @Res() res: Response) { + const rawToken = req.cookies[REFRESH_COOKIE] as string | undefined; + if (!rawToken) { + throw new UnauthorizedException("Missing refresh token"); + } + + try { + const rotated = await this.refreshTokenService.rotate(rawToken); + const { accessToken } = await this.authService.issueAccessToken( + rotated.memberId, + ); + setAuthCookies(res, accessToken, rotated.rawToken); + return res.json({ ok: true }); + } catch (err) { + if (err instanceof RefreshTokenReuseError) { + await this.authService.bumpTokenVersion(err.memberId); + clearAuthCookies(res); + throw new UnauthorizedException("Refresh token reuse detected"); + } + clearAuthCookies(res); + throw err; + } + } + + @Public() + @Post("logout") + async logout(@Req() req: Request, @Res() res: Response) { + const rawToken = req.cookies[REFRESH_COOKIE] as string | undefined; + if (rawToken) { + await this.refreshTokenService.revoke(rawToken); + } + clearAuthCookies(res); + return res.json({ ok: true }); + } + + @Get("me") + async me(@Req() req: Request) { + const user = req.user as AuthUser; + const member = await this.prisma.member.findUniqueOrThrow({ + where: { id: user.id }, + select: { + id: true, + email: true, + displayName: true, + avatarUrl: true, + }, + }); + return { + id: member.id, + email: member.email, + displayName: member.displayName, + avatar: member.avatarUrl, + permissions: user.permissions, + }; } } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index a63b0cf..4db8fdc 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -6,6 +6,8 @@ import { PassportModule } from "@nestjs/passport"; import { JwtStrategy } from "./strategies/jwt.strategy"; import { JwtAuthGuard } from "./guards/jwt-auth.guard"; import { PermissionGuard } from "./guards/permission.guard"; +import { PermissionsCacheService } from "./services/permissions-cache.service"; +import { RefreshTokenService } from "./services/refresh-token.service"; @Module({ imports: [ @@ -17,7 +19,20 @@ import { PermissionGuard } from "./guards/permission.guard"; }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, JwtAuthGuard, PermissionGuard], - exports: [AuthService, JwtAuthGuard, PermissionGuard], + providers: [ + AuthService, + JwtStrategy, + JwtAuthGuard, + PermissionGuard, + PermissionsCacheService, + RefreshTokenService, + ], + exports: [ + AuthService, + JwtAuthGuard, + PermissionGuard, + PermissionsCacheService, + RefreshTokenService, + ], }) export class AuthModule {} diff --git a/src/auth/auth.service.spec.ts b/src/auth/auth.service.spec.ts new file mode 100644 index 0000000..8947d34 --- /dev/null +++ b/src/auth/auth.service.spec.ts @@ -0,0 +1,228 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({ + createAuthorizationURL: jest.fn(), + validateAuthorizationCode: jest.fn(), + })), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { JwtService } from "@nestjs/jwt"; +import { AuthService } from "./auth.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { PermissionsCacheService } from "./services/permissions-cache.service"; +import { RefreshTokenService } from "./services/refresh-token.service"; + +describe("AuthService", () => { + let service: AuthService; + let prisma: jest.Mocked; + let jwtService: jest.Mocked; + let permissionsCache: jest.Mocked; + let refreshTokenService: jest.Mocked; + + beforeEach(async () => { + const prismaMock = { + membership: { + findMany: jest.fn(), + }, + member: { + findUnique: jest.fn(), + findUniqueOrThrow: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + memberProvider: { + create: jest.fn(), + }, + } as unknown as jest.Mocked; + + const jwtServiceMock = { + signAsync: jest.fn().mockResolvedValue("mock-token"), + } as unknown as jest.Mocked; + + const permissionsCacheMock = { + setPermissions: jest.fn().mockResolvedValue(undefined), + setTokenVersion: jest.fn().mockResolvedValue(undefined), + invalidate: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const refreshTokenServiceMock = { + issue: jest.fn().mockResolvedValue("mock-refresh-token"), + revokeAllForMember: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + { provide: PrismaService, useValue: prismaMock }, + { provide: JwtService, useValue: jwtServiceMock }, + { + provide: PermissionsCacheService, + useValue: permissionsCacheMock, + }, + { provide: RefreshTokenService, useValue: refreshTokenServiceMock }, + ], + }).compile(); + + service = module.get(AuthService); + prisma = module.get(PrismaService); + jwtService = module.get(JwtService); + permissionsCache = module.get(PermissionsCacheService); + refreshTokenService = module.get(RefreshTokenService); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + const membershipWith = ( + scopeType: string, + codes: string[], + ids: { organizationId?: string; activityId?: string } = {}, + ) => + ({ + scopeType, + organizationId: ids.organizationId ?? null, + activityId: ids.activityId ?? null, + role: { permissions: codes.map((code) => ({ permission: { code } })) }, + }) as any; + + describe("getScopedPermissions", () => { + it("buckets permissions by scope and dedupes platform codes", async () => { + prisma.membership.findMany.mockResolvedValue([ + membershipWith("PLATFORM", ["activity:manage", "role:manage"]), + membershipWith("PLATFORM", ["activity:manage"]), + membershipWith("ORG", ["org:profile"], { organizationId: "org-1" }), + membershipWith("EVENT", ["event:edit"], { activityId: "act-1" }), + ]); + + const result = await service.getScopedPermissions("member-1"); + + expect(result.platform.sort()).toEqual([ + "activity:manage", + "role:manage", + ]); + expect(result.org).toEqual({ "org-1": ["org:profile"] }); + expect(result.event).toEqual({ "act-1": ["event:edit"] }); + expect(prisma.membership.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { memberId: "member-1" } }), + ); + }); + + it("returns empty buckets when the member has no memberships", async () => { + prisma.membership.findMany.mockResolvedValue([]); + const result = await service.getScopedPermissions("member-1"); + expect(result).toEqual({ platform: [], org: {}, event: {} }); + }); + }); + + describe("devLogin", () => { + beforeEach(() => { + process.env.NODE_ENV = "development"; + }); + + afterEach(() => { + process.env.NODE_ENV = "test"; + }); + + it("should create member and cache permissions on first login", async () => { + const member = { + id: "member-1", + email: "test@example.com", + tokenVersion: 1, + } as any; + + prisma.member.findUnique.mockResolvedValue(null); + prisma.member.create.mockResolvedValue(member); + prisma.member.findUniqueOrThrow.mockResolvedValue(member); + prisma.membership.findMany.mockResolvedValue([]); + + const result = await service.devLogin("test@example.com"); + + expect(prisma.member.create).toHaveBeenCalled(); + expect(permissionsCache.setPermissions).toHaveBeenCalledWith("member-1", { + platform: [], + org: {}, + event: {}, + }); + expect(permissionsCache.setTokenVersion).toHaveBeenCalledWith( + "member-1", + 1, + ); + expect(jwtService.signAsync).toHaveBeenCalledWith({ + sub: "member-1", + v: 1, + }); + expect(refreshTokenService.issue).toHaveBeenCalledWith("member-1"); + expect(result.access_token).toBe("mock-token"); + expect(result.refresh_token).toBe("mock-refresh-token"); + expect(result.user.id).toBe("member-1"); + }); + + it("should use existing member and update cache on subsequent login", async () => { + const member = { + id: "member-1", + email: "test@example.com", + tokenVersion: 1, + } as any; + + prisma.member.findUnique.mockResolvedValue(member); + prisma.member.findUniqueOrThrow.mockResolvedValue(member); + prisma.membership.findMany.mockResolvedValue([ + membershipWith("PLATFORM", ["activity:manage"]), + ]); + + await service.devLogin("test@example.com"); + + expect(prisma.member.create).not.toHaveBeenCalled(); + expect(permissionsCache.setPermissions).toHaveBeenCalledWith("member-1", { + platform: ["activity:manage"], + org: {}, + event: {}, + }); + expect(permissionsCache.setTokenVersion).toHaveBeenCalledWith( + "member-1", + 1, + ); + }); + + it("should throw in production", async () => { + process.env.NODE_ENV = "production"; + + await expect(service.devLogin("test@example.com")).rejects.toThrow( + "This endpoint is only available in development", + ); + }); + }); + + describe("bumpTokenVersion", () => { + it("should increment tokenVersion, invalidate cache, update cache, and revoke refresh tokens", async () => { + prisma.member.update.mockResolvedValue({ + id: "member-1", + tokenVersion: 3, + } as any); + + await service.bumpTokenVersion("member-1"); + + expect(prisma.member.update).toHaveBeenCalledWith({ + where: { id: "member-1" }, + data: { tokenVersion: { increment: 1 } }, + }); + expect(permissionsCache.invalidate).toHaveBeenCalledWith("member-1"); + expect(permissionsCache.setTokenVersion).toHaveBeenCalledWith( + "member-1", + 3, + ); + expect(refreshTokenService.revokeAllForMember).toHaveBeenCalledWith( + "member-1", + ); + }); + }); + + describe("invalidateUserCache", () => { + it("should clear cache for a member", async () => { + await service.invalidateUserCache("member-1"); + + expect(permissionsCache.invalidate).toHaveBeenCalledWith("member-1"); + }); + }); +}); diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3f0f5be..2fc681b 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -3,6 +3,9 @@ import { GitHub } from "arctic"; import { PrismaService } from "../prisma/prisma.service"; import { JwtService } from "@nestjs/jwt"; import { withId } from "src/common/utils/db.util"; +import { PermissionsCacheService } from "./services/permissions-cache.service"; +import { RefreshTokenService } from "./services/refresh-token.service"; +import { ScopedPermissions } from "./types/scoped-permissions"; interface GithubProfile { id: number; @@ -23,8 +26,7 @@ interface GithubSocial { interface JwtPayload { sub: string; - email: string; - permissions: string[]; + v: number; // tokenVersion } @Injectable() @@ -35,6 +37,8 @@ export class AuthService { constructor( private prisma: PrismaService, private jwtService: JwtService, + private permissionsCache: PermissionsCacheService, + private refreshTokenService: RefreshTokenService, ) { this.github = new GitHub( process.env.GITHUB_CLIENT_ID!, @@ -62,27 +66,71 @@ export class AuthService { return response.json() as T; } - private async getUserPermissions(memberId: string): Promise { - const permissions = new Set(); - - const rolePermissions = await this.prisma.rolePermission.findMany({ - where: { - role: { - members: { - some: { memberId }, - }, - }, - }, + async getScopedPermissions(memberId: string): Promise { + const memberships = await this.prisma.membership.findMany({ + where: { memberId }, include: { - permission: true, + role: { include: { permissions: { include: { permission: true } } } }, }, }); - rolePermissions.forEach((rp) => { - permissions.add(rp.permission.code); + const platform = new Set(); + const org: Record> = {}; + const event: Record> = {}; + + for (const m of memberships) { + const codes = m.role.permissions.map((rp) => rp.permission.code); + if (m.scopeType === "PLATFORM") { + codes.forEach((c) => platform.add(c)); + } else if (m.scopeType === "ORG" && m.organizationId) { + const bucket = (org[m.organizationId] ??= new Set()); + codes.forEach((c) => bucket.add(c)); + } else if (m.scopeType === "EVENT" && m.activityId) { + const bucket = (event[m.activityId] ??= new Set()); + codes.forEach((c) => bucket.add(c)); + } + } + + const materialize = (rec: Record>) => + Object.fromEntries( + Object.entries(rec).map(([id, set]) => [id, Array.from(set)]), + ); + + return { + platform: Array.from(platform), + org: materialize(org), + event: materialize(event), + }; + } + + /** + * Warm the cache for a member and sign a fresh access JWT. + * Does NOT issue a refresh token — callers do that separately when needed. + */ + async issueAccessToken( + memberId: string, + ): Promise<{ accessToken: string; permissions: ScopedPermissions }> { + const member = await this.prisma.member.findUniqueOrThrow({ + where: { id: memberId }, }); + const permissions = await this.getScopedPermissions(member.id); + await this.permissionsCache.setPermissions(member.id, permissions); + await this.permissionsCache.setTokenVersion(member.id, member.tokenVersion); + + const payload: JwtPayload = { sub: member.id, v: member.tokenVersion }; + const accessToken = await this.jwtService.signAsync(payload); - return Array.from(permissions); + return { accessToken, permissions }; + } + + async issueSession(memberId: string): Promise<{ + accessToken: string; + refreshToken: string; + permissions: ScopedPermissions; + }> { + const { accessToken, permissions } = await this.issueAccessToken(memberId); + const refreshToken = await this.refreshTokenService.issue(memberId); + return { accessToken, refreshToken, permissions }; } async loginWithGithub(code: string) { @@ -161,16 +209,11 @@ export class AuthService { } } - const permissions = await this.getUserPermissions(user.id); - - const payload: JwtPayload = { - sub: user.id, - email: user.email, - permissions, - }; + const session = await this.issueSession(user.id); return { - access_token: await this.jwtService.signAsync(payload), + access_token: session.accessToken, + refresh_token: session.refreshToken, }; } catch (error: unknown) { if (error instanceof UnauthorizedException) { @@ -214,23 +257,30 @@ export class AuthService { }); } - const permissions = await this.getUserPermissions(member.id); - - const payload: JwtPayload = { - sub: member.id, - email: member.email, - permissions, - }; - - const access_token = await this.jwtService.signAsync(payload); + const session = await this.issueSession(member.id); return { - access_token, + access_token: session.accessToken, + refresh_token: session.refreshToken, user: { id: member.id, email: member.email, - permissions, + permissions: session.permissions, }, }; } + + async bumpTokenVersion(memberId: string): Promise { + const updated = await this.prisma.member.update({ + where: { id: memberId }, + data: { tokenVersion: { increment: 1 } }, + }); + await this.permissionsCache.invalidate(memberId); + await this.permissionsCache.setTokenVersion(memberId, updated.tokenVersion); + await this.refreshTokenService.revokeAllForMember(memberId); + } + + async invalidateUserCache(memberId: string): Promise { + await this.permissionsCache.invalidate(memberId); + } } diff --git a/src/auth/decorators/scoped-permissions.decorator.ts b/src/auth/decorators/scoped-permissions.decorator.ts new file mode 100644 index 0000000..b1246db --- /dev/null +++ b/src/auth/decorators/scoped-permissions.decorator.ts @@ -0,0 +1,30 @@ +import { SetMetadata } from "@nestjs/common"; + +export type PermissionScope = "PLATFORM" | "ORG" | "EVENT"; + +export interface ScopedPermissionRequirement { + /** Which scope the required permissions must be satisfied in. */ + scope: PermissionScope; + /** Permission codes required (all must be granted). */ + perms: string[]; + /** + * Route param holding the scope id. Required for ORG/EVENT. + * e.g. param: "activityId" reads `req.params.activityId`. + */ + param?: string; +} + +export const SCOPED_PERMISSIONS_KEY = "scoped_permissions"; + +/** + * Require permissions within a given scope. The PermissionGuard resolves the + * scope id from the route param and checks the matching permission bucket. + * + * PLATFORM permissions always satisfy any scope; ORG permissions cascade down + * to that org's events. + * + * @example + * @RequirePermissions({ scope: "EVENT", param: "activityId", perms: ["event:edit"] }) + */ +export const RequirePermissions = (req: ScopedPermissionRequirement) => + SetMetadata(SCOPED_PERMISSIONS_KEY, req); diff --git a/src/auth/guards/permission.guard.spec.ts b/src/auth/guards/permission.guard.spec.ts new file mode 100644 index 0000000..92b6951 --- /dev/null +++ b/src/auth/guards/permission.guard.spec.ts @@ -0,0 +1,179 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { Reflector } from "@nestjs/core"; +import { ExecutionContext } from "@nestjs/common"; +import { PermissionGuard } from "./permission.guard"; +import { AuthUser } from "../strategies/jwt.strategy"; +import { PrismaService } from "../../prisma/prisma.service"; +import { PermissionsCacheService } from "../services/permissions-cache.service"; +import { + SCOPED_PERMISSIONS_KEY, + ScopedPermissionRequirement, +} from "../decorators/scoped-permissions.decorator"; +import { ScopedPermissions } from "../types/scoped-permissions"; + +describe("PermissionGuard", () => { + let guard: PermissionGuard; + let reflector: jest.Mocked; + let prisma: jest.Mocked; + let cache: jest.Mocked; + + const context = ( + user: AuthUser | undefined, + params: Record = {}, + ): ExecutionContext => { + const request = { user, params }; + return { + switchToHttp: () => ({ getRequest: () => request }), + getHandler: () => ({}), + getClass: () => ({}), + } as unknown as ExecutionContext; + }; + + const setMetadata = ( + scoped?: ScopedPermissionRequirement, + legacy?: string[], + ) => { + reflector.getAllAndOverride.mockImplementation((key: string) => + key === SCOPED_PERMISSIONS_KEY ? scoped : legacy, + ); + }; + + const user = (permissions: Partial): AuthUser => ({ + id: "member-1", + tokenVersion: 1, + permissions: { platform: [], org: {}, event: {}, ...permissions }, + }); + + beforeEach(async () => { + const reflectorMock = { + getAllAndOverride: jest.fn(), + } as unknown as jest.Mocked; + const prismaMock = { + activity: { findUnique: jest.fn() }, + } as unknown as jest.Mocked; + const cacheMock = { + getActivityOrg: jest.fn().mockResolvedValue(undefined), + setActivityOrg: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionGuard, + { provide: Reflector, useValue: reflectorMock }, + { provide: PrismaService, useValue: prismaMock }, + { provide: PermissionsCacheService, useValue: cacheMock }, + ], + }).compile(); + + guard = module.get(PermissionGuard); + reflector = module.get(Reflector); + prisma = module.get(PrismaService); + cache = module.get(PermissionsCacheService); + }); + + it("allows when no permissions are required", async () => { + setMetadata(undefined, undefined); + expect(await guard.canActivate(context(undefined))).toBe(true); + }); + + it("denies when user is not authenticated", async () => { + setMetadata(undefined, ["activity:manage"]); + expect(await guard.canActivate(context(undefined))).toBe(false); + }); + + describe("legacy @Permissions (platform scope)", () => { + it("allows when platform bucket has all codes", async () => { + setMetadata(undefined, ["activity:manage"]); + const ctx = context(user({ platform: ["activity:manage"] })); + expect(await guard.canActivate(ctx)).toBe(true); + }); + + it("denies when a platform code is missing", async () => { + setMetadata(undefined, ["activity:manage", "role:manage"]); + const ctx = context(user({ platform: ["activity:manage"] })); + expect(await guard.canActivate(ctx)).toBe(false); + }); + }); + + describe("ORG scope", () => { + const req: ScopedPermissionRequirement = { + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }; + + it("allows when the org bucket grants the permission", async () => { + setMetadata(req); + const ctx = context(user({ org: { "org-1": ["org:member:manage"] } }), { + orgId: "org-1", + }); + expect(await guard.canActivate(ctx)).toBe(true); + }); + + it("allows when a platform grant covers it", async () => { + setMetadata(req); + const ctx = context(user({ platform: ["org:member:manage"] }), { + orgId: "org-1", + }); + expect(await guard.canActivate(ctx)).toBe(true); + }); + + it("denies when the org bucket lacks it", async () => { + setMetadata(req); + const ctx = context(user({ org: { "org-2": ["org:member:manage"] } }), { + orgId: "org-1", + }); + expect(await guard.canActivate(ctx)).toBe(false); + }); + }); + + describe("EVENT scope", () => { + const req: ScopedPermissionRequirement = { + scope: "EVENT", + param: "activityId", + perms: ["event:edit"], + }; + + it("allows on a direct event grant", async () => { + setMetadata(req); + const ctx = context(user({ event: { "act-1": ["event:edit"] } }), { + activityId: "act-1", + }); + expect(await guard.canActivate(ctx)).toBe(true); + expect(prisma.activity.findUnique).not.toHaveBeenCalled(); + }); + + it("cascades to the activity's organization", async () => { + setMetadata(req); + prisma.activity.findUnique.mockResolvedValue({ + organizationId: "org-1", + } as any); + const ctx = context(user({ org: { "org-1": ["event:edit"] } }), { + activityId: "act-1", + }); + expect(await guard.canActivate(ctx)).toBe(true); + expect(cache.setActivityOrg).toHaveBeenCalledWith("act-1", "org-1"); + }); + + it("denies when neither event nor parent org grants it", async () => { + setMetadata(req); + prisma.activity.findUnique.mockResolvedValue({ + organizationId: "org-1", + } as any); + const ctx = context(user({ org: { "org-1": ["event:report"] } }), { + activityId: "act-1", + }); + expect(await guard.canActivate(ctx)).toBe(false); + }); + + it("uses the cached activity->org mapping when present", async () => { + setMetadata(req); + cache.getActivityOrg.mockResolvedValue("org-1"); + const ctx = context(user({ org: { "org-1": ["event:edit"] } }), { + activityId: "act-1", + }); + expect(await guard.canActivate(ctx)).toBe(true); + expect(prisma.activity.findUnique).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/auth/guards/permission.guard.ts b/src/auth/guards/permission.guard.ts index 54fc800..2bee5cd 100644 --- a/src/auth/guards/permission.guard.ts +++ b/src/auth/guards/permission.guard.ts @@ -2,30 +2,91 @@ import { Injectable, CanActivate, ExecutionContext } from "@nestjs/common"; import { Reflector } from "@nestjs/core"; import { Request } from "express"; import { AuthUser } from "../strategies/jwt.strategy"; +import { PrismaService } from "../../prisma/prisma.service"; +import { PermissionsCacheService } from "../services/permissions-cache.service"; +import { + SCOPED_PERMISSIONS_KEY, + ScopedPermissionRequirement, +} from "../decorators/scoped-permissions.decorator"; @Injectable() export class PermissionGuard implements CanActivate { - constructor(private reflector: Reflector) {} + constructor( + private reflector: Reflector, + private prisma: PrismaService, + private cache: PermissionsCacheService, + ) {} - canActivate(context: ExecutionContext): boolean { - const requiredPermissions = this.reflector.getAllAndOverride( - "permissions", - [context.getHandler(), context.getClass()], - ); + async canActivate(context: ExecutionContext): Promise { + const scoped = + this.reflector.getAllAndOverride( + SCOPED_PERMISSIONS_KEY, + [context.getHandler(), context.getClass()], + ); + // Legacy @Permissions(...) → treated as a PLATFORM-scope requirement. + const legacy = this.reflector.getAllAndOverride("permissions", [ + context.getHandler(), + context.getClass(), + ]); - if (!requiredPermissions) { + if (!scoped && !legacy) { return true; } const request = context.switchToHttp().getRequest(); - const user = request.user as AuthUser; - + const user = request.user as AuthUser | undefined; if (!user) { return false; } - return requiredPermissions.every((permission) => - user.permissions.includes(permission), - ); + const requirement: ScopedPermissionRequirement = scoped ?? { + scope: "PLATFORM", + perms: legacy, + }; + + // PLATFORM grants always count. + const allowed = new Set(user.permissions.platform); + const satisfied = () => requirement.perms.every((p) => allowed.has(p)); + + if (requirement.scope === "PLATFORM" || satisfied()) { + return satisfied(); + } + + const scopeId = requirement.param + ? (request.params[requirement.param] as string | undefined) + : undefined; + if (!scopeId) { + return false; + } + + if (requirement.scope === "ORG") { + (user.permissions.org[scopeId] ?? []).forEach((c) => allowed.add(c)); + return satisfied(); + } + + // EVENT: try direct grants on the activity first, then cascade from its org. + (user.permissions.event[scopeId] ?? []).forEach((c) => allowed.add(c)); + if (satisfied()) { + return true; + } + const orgId = await this.resolveActivityOrg(scopeId); + if (orgId) { + (user.permissions.org[orgId] ?? []).forEach((c) => allowed.add(c)); + } + return satisfied(); + } + + private async resolveActivityOrg(activityId: string): Promise { + const cached = await this.cache.getActivityOrg(activityId); + if (cached !== undefined) { + return cached === "" ? null : cached; + } + const activity = await this.prisma.activity.findUnique({ + where: { id: activityId }, + select: { organizationId: true }, + }); + const orgId = activity?.organizationId ?? null; + await this.cache.setActivityOrg(activityId, orgId); + return orgId; } } diff --git a/src/auth/services/permissions-cache.service.spec.ts b/src/auth/services/permissions-cache.service.spec.ts new file mode 100644 index 0000000..c9e0ae6 --- /dev/null +++ b/src/auth/services/permissions-cache.service.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import type { Cache } from "cache-manager"; +import { PermissionsCacheService } from "./permissions-cache.service"; + +describe("PermissionsCacheService", () => { + let service: PermissionsCacheService; + let cacheManager: Cache; + + beforeEach(async () => { + const cacheMock = { + get: jest.fn(), + set: jest.fn(), + del: jest.fn(), + } as unknown as Cache; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionsCacheService, + { provide: CACHE_MANAGER, useValue: cacheMock }, + ], + }).compile(); + + service = module.get(PermissionsCacheService); + cacheManager = module.get(CACHE_MANAGER); + }); + + it("should be defined", () => { + expect(service).toBeDefined(); + }); + + describe("getPermissions / setPermissions", () => { + it("should return undefined when not cached", async () => { + jest.spyOn(cacheManager, "get").mockResolvedValue(undefined); + const result = await service.getPermissions("member-1"); + expect(result).toBeUndefined(); + expect(cacheManager.get).toHaveBeenCalledWith("perms:member-1"); + }); + + it("should set and get permissions", async () => { + const perms = { + platform: ["activity:manage"], + org: { "org-1": ["org:profile"] }, + event: {}, + }; + jest.spyOn(cacheManager, "set").mockResolvedValue(undefined); + jest.spyOn(cacheManager, "get").mockResolvedValue(perms); + + await service.setPermissions("member-1", perms); + const result = await service.getPermissions("member-1"); + + expect(cacheManager.set).toHaveBeenCalledWith("perms:member-1", perms); + expect(result).toEqual(perms); + }); + }); + + describe("activity -> org mapping", () => { + it("stores empty string when the activity has no org", async () => { + jest.spyOn(cacheManager, "set").mockResolvedValue(undefined); + await service.setActivityOrg("act-1", null); + expect(cacheManager.set).toHaveBeenCalledWith("act_org:act-1", ""); + }); + + it("stores the org id when present", async () => { + jest.spyOn(cacheManager, "set").mockResolvedValue(undefined); + await service.setActivityOrg("act-1", "org-1"); + expect(cacheManager.set).toHaveBeenCalledWith("act_org:act-1", "org-1"); + }); + }); + + describe("getTokenVersion / setTokenVersion", () => { + it("should return undefined when not cached", async () => { + jest.spyOn(cacheManager, "get").mockResolvedValue(undefined); + const result = await service.getTokenVersion("member-1"); + expect(result).toBeUndefined(); + expect(cacheManager.get).toHaveBeenCalledWith("tv:member-1"); + }); + + it("should set and get tokenVersion", async () => { + jest.spyOn(cacheManager, "set").mockResolvedValue(undefined); + jest.spyOn(cacheManager, "get").mockResolvedValue(2); + + await service.setTokenVersion("member-1", 2); + const result = await service.getTokenVersion("member-1"); + + expect(cacheManager.set).toHaveBeenCalledWith("tv:member-1", 2); + expect(result).toBe(2); + }); + }); + + describe("invalidate", () => { + it("should delete both permissions and tokenVersion keys", async () => { + jest.spyOn(cacheManager, "del").mockResolvedValue(undefined); + + await service.invalidate("member-1"); + + expect(cacheManager.del).toHaveBeenCalledWith("perms:member-1"); + expect(cacheManager.del).toHaveBeenCalledWith("tv:member-1"); + }); + }); +}); diff --git a/src/auth/services/permissions-cache.service.ts b/src/auth/services/permissions-cache.service.ts new file mode 100644 index 0000000..254cc42 --- /dev/null +++ b/src/auth/services/permissions-cache.service.ts @@ -0,0 +1,63 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { CACHE_MANAGER } from "@nestjs/cache-manager"; +import type { Cache } from "cache-manager"; +import { ScopedPermissions } from "../types/scoped-permissions"; + +@Injectable() +export class PermissionsCacheService { + constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} + + private permsKey(memberId: string): string { + return `perms:${memberId}`; + } + + private tvKey(memberId: string): string { + return `tv:${memberId}`; + } + + async getPermissions( + memberId: string, + ): Promise { + return this.cacheManager.get(this.permsKey(memberId)); + } + + async setPermissions( + memberId: string, + permissions: ScopedPermissions, + ): Promise { + await this.cacheManager.set(this.permsKey(memberId), permissions); + } + + async getTokenVersion(memberId: string): Promise { + return this.cacheManager.get(this.tvKey(memberId)); + } + + async setTokenVersion(memberId: string, version: number): Promise { + await this.cacheManager.set(this.tvKey(memberId), version); + } + + async invalidate(memberId: string): Promise { + await this.cacheManager.del(this.permsKey(memberId)); + await this.cacheManager.del(this.tvKey(memberId)); + } + + // --- activity -> organization mapping (for org->event permission cascade) --- + // Stores "" when the activity has no organization, undefined means cache miss. + private actOrgKey(activityId: string): string { + return `act_org:${activityId}`; + } + + async getActivityOrg(activityId: string): Promise { + return this.cacheManager.get(this.actOrgKey(activityId)); + } + + async setActivityOrg( + activityId: string, + organizationId: string | null, + ): Promise { + await this.cacheManager.set( + this.actOrgKey(activityId), + organizationId ?? "", + ); + } +} diff --git a/src/auth/services/refresh-token.service.spec.ts b/src/auth/services/refresh-token.service.spec.ts new file mode 100644 index 0000000..0f59f52 --- /dev/null +++ b/src/auth/services/refresh-token.service.spec.ts @@ -0,0 +1,130 @@ +import { Test, TestingModule } from "@nestjs/testing"; +import { UnauthorizedException } from "@nestjs/common"; +import { createHash } from "crypto"; +import { + RefreshTokenService, + RefreshTokenReuseError, +} from "./refresh-token.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +const sha = (s: string) => createHash("sha256").update(s).digest("hex"); + +describe("RefreshTokenService", () => { + let service: RefreshTokenService; + let prisma: jest.Mocked; + + beforeEach(async () => { + const prismaMock = { + refreshToken: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + }, + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RefreshTokenService, + { provide: PrismaService, useValue: prismaMock }, + ], + }).compile(); + + service = module.get(RefreshTokenService); + prisma = module.get(PrismaService); + }); + + describe("issue", () => { + it("creates a refresh token with hashed value", async () => { + (prisma.refreshToken.create as jest.Mock).mockResolvedValue({}); + const raw = await service.issue("m-1"); + + expect(typeof raw).toBe("string"); + expect(raw.length).toBeGreaterThan(20); + const call = (prisma.refreshToken.create as jest.Mock).mock.calls[0][0]; + expect(call.data.memberId).toBe("m-1"); + expect(call.data.tokenHash).toBe(sha(raw)); + expect(call.data.expiresAt).toBeInstanceOf(Date); + }); + }); + + describe("rotate", () => { + it("rejects when token is unknown", async () => { + (prisma.refreshToken.findUnique as jest.Mock).mockResolvedValue(null); + await expect(service.rotate("nope")).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it("throws RefreshTokenReuseError when token already revoked", async () => { + (prisma.refreshToken.findUnique as jest.Mock).mockResolvedValue({ + id: "rt-1", + memberId: "m-1", + revokedAt: new Date(), + expiresAt: new Date(Date.now() + 1000), + }); + await expect(service.rotate("stale")).rejects.toBeInstanceOf( + RefreshTokenReuseError, + ); + }); + + it("rejects when token is expired", async () => { + (prisma.refreshToken.findUnique as jest.Mock).mockResolvedValue({ + id: "rt-1", + memberId: "m-1", + revokedAt: null, + expiresAt: new Date(Date.now() - 1000), + }); + await expect(service.rotate("old")).rejects.toBeInstanceOf( + UnauthorizedException, + ); + }); + + it("issues a new token and revokes the old on happy path", async () => { + (prisma.refreshToken.findUnique as jest.Mock).mockResolvedValue({ + id: "rt-1", + memberId: "m-1", + revokedAt: null, + expiresAt: new Date(Date.now() + 60_000), + }); + (prisma.refreshToken.create as jest.Mock).mockResolvedValue({ + id: "rt-2", + }); + (prisma.refreshToken.update as jest.Mock).mockResolvedValue({}); + + const result = await service.rotate("valid"); + expect(result.memberId).toBe("m-1"); + expect(typeof result.rawToken).toBe("string"); + expect(prisma.refreshToken.update).toHaveBeenCalledWith({ + where: { id: "rt-1" }, + data: { revokedAt: expect.any(Date), replacedBy: "rt-2" }, + }); + }); + }); + + describe("revoke", () => { + it("marks the matching token revoked", async () => { + (prisma.refreshToken.updateMany as jest.Mock).mockResolvedValue({ + count: 1, + }); + await service.revoke("raw"); + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { tokenHash: sha("raw"), revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + }); + + describe("revokeAllForMember", () => { + it("revokes all active tokens for a member", async () => { + (prisma.refreshToken.updateMany as jest.Mock).mockResolvedValue({ + count: 3, + }); + await service.revokeAllForMember("m-1"); + expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({ + where: { memberId: "m-1", revokedAt: null }, + data: { revokedAt: expect.any(Date) }, + }); + }); + }); +}); diff --git a/src/auth/services/refresh-token.service.ts b/src/auth/services/refresh-token.service.ts new file mode 100644 index 0000000..6f3f8e9 --- /dev/null +++ b/src/auth/services/refresh-token.service.ts @@ -0,0 +1,105 @@ +import { Injectable, UnauthorizedException } from "@nestjs/common"; +import { randomBytes, createHash } from "crypto"; +import { PrismaService } from "../../prisma/prisma.service"; +import { withId } from "src/common/utils/db.util"; + +const REFRESH_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +export interface RotateResult { + memberId: string; + rawToken: string; +} + +@Injectable() +export class RefreshTokenService { + constructor(private readonly prisma: PrismaService) {} + + private hash(raw: string): string { + return createHash("sha256").update(raw).digest("hex"); + } + + private generateRaw(): string { + return randomBytes(32).toString("base64url"); + } + + async issue(memberId: string): Promise { + const rawToken = this.generateRaw(); + const expiresAt = new Date(Date.now() + REFRESH_TOKEN_TTL_MS); + + await this.prisma.refreshToken.create({ + data: withId({ + memberId, + tokenHash: this.hash(rawToken), + expiresAt, + }), + }); + + return rawToken; + } + + /** + * Rotate a refresh token. Returns the new raw token + memberId. + * If the presented token is already revoked, treats it as theft: + * the caller should bump tokenVersion to kill all sessions. + */ + async rotate( + rawToken: string, + ): Promise { + const tokenHash = this.hash(rawToken); + const existing = await this.prisma.refreshToken.findUnique({ + where: { tokenHash }, + }); + + if (!existing) { + throw new UnauthorizedException("Invalid refresh token"); + } + + if (existing.revokedAt) { + // Reuse detected — caller must bump tokenVersion. + throw new RefreshTokenReuseError(existing.memberId); + } + + if (existing.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedException("Refresh token expired"); + } + + const newRaw = this.generateRaw(); + const newExpires = new Date(Date.now() + REFRESH_TOKEN_TTL_MS); + + const created = await this.prisma.refreshToken.create({ + data: withId({ + memberId: existing.memberId, + tokenHash: this.hash(newRaw), + expiresAt: newExpires, + }), + }); + + await this.prisma.refreshToken.update({ + where: { id: existing.id }, + data: { revokedAt: new Date(), replacedBy: created.id }, + }); + + return { memberId: existing.memberId, rawToken: newRaw }; + } + + async revoke(rawToken: string): Promise { + const tokenHash = this.hash(rawToken); + await this.prisma.refreshToken.updateMany({ + where: { tokenHash, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } + + async revokeAllForMember(memberId: string): Promise { + await this.prisma.refreshToken.updateMany({ + where: { memberId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + } +} + +export class RefreshTokenReuseError extends Error { + constructor(public readonly memberId: string) { + super("Refresh token reuse detected"); + } +} diff --git a/src/auth/strategies/jwt.strategy.spec.ts b/src/auth/strategies/jwt.strategy.spec.ts new file mode 100644 index 0000000..7c7208e --- /dev/null +++ b/src/auth/strategies/jwt.strategy.spec.ts @@ -0,0 +1,158 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({ + createAuthorizationURL: jest.fn(), + validateAuthorizationCode: jest.fn(), + })), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { ConfigService } from "@nestjs/config"; +import { UnauthorizedException } from "@nestjs/common"; +import { JwtStrategy, JwtPayloadToken } from "./jwt.strategy"; +import { PermissionsCacheService } from "../services/permissions-cache.service"; +import { AuthService } from "../auth.service"; +import { PrismaService } from "../../prisma/prisma.service"; + +describe("JwtStrategy", () => { + let strategy: JwtStrategy; + let permissionsCache: jest.Mocked; + let prisma: jest.Mocked; + let authService: jest.Mocked; + + beforeEach(async () => { + const permissionsCacheMock = { + getTokenVersion: jest.fn(), + setTokenVersion: jest.fn(), + getPermissions: jest.fn(), + setPermissions: jest.fn(), + } as unknown as jest.Mocked; + + const prismaMock = { + member: { + findUnique: jest.fn(), + }, + } as unknown as jest.Mocked; + + const authServiceMock = { + getScopedPermissions: jest.fn(), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtStrategy, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue("test-secret") }, + }, + { provide: PermissionsCacheService, useValue: permissionsCacheMock }, + { provide: AuthService, useValue: authServiceMock }, + { provide: PrismaService, useValue: prismaMock }, + ], + }).compile(); + + strategy = module.get(JwtStrategy); + permissionsCache = module.get(PermissionsCacheService); + prisma = module.get(PrismaService); + authService = module.get(AuthService); + }); + + it("should be defined", () => { + expect(strategy).toBeDefined(); + }); + + describe("validate", () => { + it("should return AuthUser when tokenVersion matches and permissions are cached", async () => { + const payload: JwtPayloadToken = { sub: "member-1", v: 1 }; + const cachedPermissions = { + platform: ["activity:manage"], + org: {}, + event: {}, + }; + + permissionsCache.getTokenVersion.mockResolvedValue(1); + permissionsCache.getPermissions.mockResolvedValue(cachedPermissions); + + const result = await strategy.validate(payload); + + expect(result).toEqual({ + id: "member-1", + permissions: cachedPermissions, + tokenVersion: 1, + }); + expect(permissionsCache.getTokenVersion).toHaveBeenCalledWith("member-1"); + expect(permissionsCache.getPermissions).toHaveBeenCalledWith("member-1"); + }); + + it("should fallback to DB when tokenVersion is not cached", async () => { + const payload: JwtPayloadToken = { sub: "member-1", v: 1 }; + const cachedPermissions = { + platform: ["activity:manage"], + org: {}, + event: {}, + }; + + permissionsCache.getTokenVersion.mockResolvedValue(undefined); + prisma.member.findUnique.mockResolvedValue({ + tokenVersion: 1, + } as any); + permissionsCache.setTokenVersion.mockResolvedValue(undefined); + permissionsCache.getPermissions.mockResolvedValue(cachedPermissions); + + const result = await strategy.validate(payload); + + expect(prisma.member.findUnique).toHaveBeenCalledWith({ + where: { id: "member-1" }, + select: { tokenVersion: true }, + }); + expect(permissionsCache.setTokenVersion).toHaveBeenCalledWith( + "member-1", + 1, + ); + expect(result.tokenVersion).toBe(1); + }); + + it("should fallback to DB when permissions are not cached", async () => { + const payload: JwtPayloadToken = { sub: "member-1", v: 1 }; + const dbPermissions = { + platform: ["activity:manage"], + org: { "org-1": ["org:profile"] }, + event: {}, + }; + + permissionsCache.getTokenVersion.mockResolvedValue(1); + permissionsCache.getPermissions.mockResolvedValue(undefined); + authService.getScopedPermissions.mockResolvedValue(dbPermissions); + permissionsCache.setPermissions.mockResolvedValue(undefined); + + const result = await strategy.validate(payload); + + expect(authService.getScopedPermissions).toHaveBeenCalledWith("member-1"); + expect(permissionsCache.setPermissions).toHaveBeenCalledWith( + "member-1", + dbPermissions, + ); + expect(result.permissions).toEqual(dbPermissions); + }); + + it("should throw UnauthorizedException when tokenVersion mismatches", async () => { + const payload: JwtPayloadToken = { sub: "member-1", v: 1 }; + + permissionsCache.getTokenVersion.mockResolvedValue(2); + + await expect(strategy.validate(payload)).rejects.toThrow( + new UnauthorizedException("Token has been revoked"), + ); + }); + + it("should throw UnauthorizedException when user no longer exists", async () => { + const payload: JwtPayloadToken = { sub: "member-1", v: 1 }; + + permissionsCache.getTokenVersion.mockResolvedValue(undefined); + prisma.member.findUnique.mockResolvedValue(null); + + await expect(strategy.validate(payload)).rejects.toThrow( + new UnauthorizedException("User no longer exists"), + ); + }); + }); +}); diff --git a/src/auth/strategies/jwt.strategy.ts b/src/auth/strategies/jwt.strategy.ts index ff8a3f0..37bc0a9 100644 --- a/src/auth/strategies/jwt.strategy.ts +++ b/src/auth/strategies/jwt.strategy.ts @@ -1,24 +1,32 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, UnauthorizedException } from "@nestjs/common"; import { PassportStrategy } from "@nestjs/passport"; import { ExtractJwt, Strategy } from "passport-jwt"; import { ConfigService } from "@nestjs/config"; import { Request } from "express"; +import { PermissionsCacheService } from "../services/permissions-cache.service"; +import { AuthService } from "../auth.service"; +import { PrismaService } from "../../prisma/prisma.service"; +import { ScopedPermissions } from "../types/scoped-permissions"; export interface JwtPayloadToken { sub: string; - permissions: string[]; v: number; // tokenVersion } export interface AuthUser { id: string; - permissions: string[]; + permissions: ScopedPermissions; tokenVersion: number; } @Injectable() export class JwtStrategy extends PassportStrategy(Strategy) { - constructor(private configService: ConfigService) { + constructor( + private configService: ConfigService, + private permissionsCache: PermissionsCacheService, + private authService: AuthService, + private prisma: PrismaService, + ) { super({ jwtFromRequest: ExtractJwt.fromExtractors([ (request: Request) => { @@ -31,10 +39,35 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: JwtPayloadToken): AuthUser { + async validate(payload: JwtPayloadToken): Promise { + let tokenVersion = await this.permissionsCache.getTokenVersion(payload.sub); + + if (tokenVersion === undefined) { + const member = await this.prisma.member.findUnique({ + where: { id: payload.sub }, + select: { tokenVersion: true }, + }); + if (!member) { + throw new UnauthorizedException("User no longer exists"); + } + tokenVersion = member.tokenVersion; + await this.permissionsCache.setTokenVersion(payload.sub, tokenVersion); + } + + if (payload.v !== tokenVersion) { + throw new UnauthorizedException("Token has been revoked"); + } + + let permissions = await this.permissionsCache.getPermissions(payload.sub); + + if (permissions === undefined) { + permissions = await this.authService.getScopedPermissions(payload.sub); + await this.permissionsCache.setPermissions(payload.sub, permissions); + } + return { id: payload.sub, - permissions: payload.permissions, + permissions, tokenVersion: payload.v, }; } diff --git a/src/auth/types/scoped-permissions.ts b/src/auth/types/scoped-permissions.ts new file mode 100644 index 0000000..d2179fd --- /dev/null +++ b/src/auth/types/scoped-permissions.ts @@ -0,0 +1,20 @@ +/** + * Permissions resolved for a member, bucketed by scope. + * - `platform`: codes granted via PLATFORM-scoped memberships (apply everywhere) + * - `org`: orgId -> codes granted on that organization + * - `event`: activityId -> codes granted directly on that activity + * + * Org permissions cascade down to that org's events; the cascade is resolved + * at check time in PermissionGuard (it is not pre-expanded here). + */ +export interface ScopedPermissions { + platform: string[]; + org: Record; + event: Record; +} + +export const emptyScopedPermissions = (): ScopedPermissions => ({ + platform: [], + org: {}, + event: {}, +}); diff --git a/src/invitation/dto/invitation.dto.ts b/src/invitation/dto/invitation.dto.ts new file mode 100644 index 0000000..33d9df3 --- /dev/null +++ b/src/invitation/dto/invitation.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEmail, IsString, IsUUID } from "class-validator"; + +export class CreateInvitationDto { + @ApiProperty({ example: "teammate@example.com" }) + @IsEmail() + email: string; + + @ApiProperty({ + description: "Role to grant on acceptance (must match scope)", + }) + @IsUUID() + roleId: string; +} + +export class RespondInvitationDto { + @ApiProperty({ description: "Invitation token from the email" }) + @IsString() + token: string; +} diff --git a/src/invitation/invitation.controller.ts b/src/invitation/invitation.controller.ts new file mode 100644 index 0000000..21a4cfe --- /dev/null +++ b/src/invitation/invitation.controller.ts @@ -0,0 +1,141 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Post, + Req, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { InvitationService } from "./invitation.service"; +import { + CreateInvitationDto, + RespondInvitationDto, +} from "./dto/invitation.dto"; +import { RequirePermissions } from "../auth/decorators/scoped-permissions.decorator"; +import type { AuthUser } from "../auth/strategies/jwt.strategy"; + +@ApiTags("Organization Invitations") +@Controller("organizations/:orgId/invitations") +export class OrganizationInvitationController { + constructor(private readonly invitationService: InvitationService) {} + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Get() + list(@Param("orgId", new ParseUUIDPipe()) orgId: string) { + return this.invitationService.listForOrg(orgId); + } + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Post() + invite( + @Param("orgId", new ParseUUIDPipe()) orgId: string, + @Body() dto: CreateInvitationDto, + @Req() req: Request, + ) { + const user = req.user as AuthUser; + return this.invitationService.inviteToOrg(orgId, dto, user.id); + } + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Post(":id/resend") + resend(@Param("id", new ParseUUIDPipe()) id: string) { + return this.invitationService.resend(id); + } + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Delete(":id") + revoke(@Param("id", new ParseUUIDPipe()) id: string) { + return this.invitationService.revoke(id); + } +} + +@ApiTags("Activity Invitations") +@Controller("activities/:activityId/invitations") +export class ActivityInvitationController { + constructor(private readonly invitationService: InvitationService) {} + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Get() + list(@Param("activityId", new ParseUUIDPipe()) activityId: string) { + return this.invitationService.listForActivity(activityId); + } + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Post() + invite( + @Param("activityId", new ParseUUIDPipe()) activityId: string, + @Body() dto: CreateInvitationDto, + @Req() req: Request, + ) { + const user = req.user as AuthUser; + return this.invitationService.inviteToActivity(activityId, dto, user.id); + } + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Post(":id/resend") + resend(@Param("id", new ParseUUIDPipe()) id: string) { + return this.invitationService.resend(id); + } + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Delete(":id") + revoke(@Param("id", new ParseUUIDPipe()) id: string) { + return this.invitationService.revoke(id); + } +} + +// Invitee-facing endpoints. Require auth (we match the caller's email), but no +// special permission — the token + email pairing is the authorization. +@ApiTags("Invitations") +@Controller("invitations") +export class InvitationController { + constructor(private readonly invitationService: InvitationService) {} + + @Post("accept") + accept(@Body() dto: RespondInvitationDto, @Req() req: Request) { + const user = req.user as AuthUser; + return this.invitationService.accept(dto.token, user.id); + } + + @Post("reject") + reject(@Body() dto: RespondInvitationDto, @Req() req: Request) { + const user = req.user as AuthUser; + return this.invitationService.reject(dto.token, user.id); + } +} diff --git a/src/invitation/invitation.module.ts b/src/invitation/invitation.module.ts new file mode 100644 index 0000000..3b942b6 --- /dev/null +++ b/src/invitation/invitation.module.ts @@ -0,0 +1,21 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { InvitationService } from "./invitation.service"; +import { MailService } from "./mail.service"; +import { + ActivityInvitationController, + InvitationController, + OrganizationInvitationController, +} from "./invitation.controller"; + +@Module({ + imports: [AuthModule], + controllers: [ + OrganizationInvitationController, + ActivityInvitationController, + InvitationController, + ], + providers: [InvitationService, MailService], + exports: [InvitationService], +}) +export class InvitationModule {} diff --git a/src/invitation/invitation.service.spec.ts b/src/invitation/invitation.service.spec.ts new file mode 100644 index 0000000..4770162 --- /dev/null +++ b/src/invitation/invitation.service.spec.ts @@ -0,0 +1,164 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({})), + generateState: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { + ForbiddenException, + GoneException, + ConflictException, +} from "@nestjs/common"; +import { InvitationService } from "./invitation.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; +import { MailService } from "./mail.service"; + +describe("InvitationService", () => { + let service: InvitationService; + let prisma: any; + let authService: jest.Mocked; + let mail: jest.Mocked; + + beforeEach(async () => { + prisma = { + organization: { findUnique: jest.fn() }, + activity: { findUnique: jest.fn() }, + role: { findUnique: jest.fn() }, + member: { findUnique: jest.fn() }, + membership: { findFirst: jest.fn(), create: jest.fn() }, + invitation: { + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + $transaction: jest.fn((fn: any) => fn(prisma)), + }; + authService = { + bumpTokenVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + mail = { + sendInvitation: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + InvitationService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: authService }, + { provide: MailService, useValue: mail }, + ], + }).compile(); + + service = module.get(InvitationService); + }); + + describe("inviteToOrg", () => { + it("creates a PENDING invitation with a ~7 day expiry and emails it", async () => { + prisma.organization.findUnique.mockResolvedValue({ id: "org-1" }); + prisma.role.findUnique.mockResolvedValue({ scope: "ORG" }); + prisma.invitation.create.mockImplementation(({ data }: any) => ({ + ...data, + })); + + const before = Date.now(); + const inv = await service.inviteToOrg( + "org-1", + { email: "Foo@Example.com", roleId: "role-1" }, + "admin-1", + ); + + expect(inv.email).toBe("foo@example.com"); // normalised + expect(inv.status).toBe("PENDING"); + const ttl = new Date(inv.expiresAt).getTime() - before; + expect(ttl).toBeGreaterThan(6.9 * 24 * 3600 * 1000); + expect(ttl).toBeLessThan(7.1 * 24 * 3600 * 1000); + expect(mail.sendInvitation).toHaveBeenCalled(); + }); + + it("rejects a role whose scope does not match", async () => { + prisma.organization.findUnique.mockResolvedValue({ id: "org-1" }); + prisma.role.findUnique.mockResolvedValue({ scope: "EVENT" }); + + await expect( + service.inviteToOrg( + "org-1", + { email: "a@b.com", roleId: "role-1" }, + "admin-1", + ), + ).rejects.toThrow(/ORG-scoped/); + }); + }); + + describe("accept", () => { + const pending = { + id: "inv-1", + email: "invitee@example.com", + roleId: "role-1", + scopeType: "ORG", + organizationId: "org-1", + activityId: null, + status: "PENDING", + expiresAt: new Date(Date.now() + 60_000), + }; + + it("creates the membership and bumps the accepting member", async () => { + prisma.invitation.findUnique.mockResolvedValue({ ...pending }); + prisma.member.findUnique.mockResolvedValue({ + id: "m-1", + email: "invitee@example.com", + }); + prisma.membership.findFirst.mockResolvedValue(null); + + const res = await service.accept("tok", "m-1"); + + expect(res).toEqual({ ok: true }); + expect(prisma.membership.create).toHaveBeenCalled(); + expect(prisma.invitation.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "ACCEPTED" }), + }), + ); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-1"); + }); + + it("forbids accepting with a mismatched email", async () => { + prisma.invitation.findUnique.mockResolvedValue({ ...pending }); + prisma.member.findUnique.mockResolvedValue({ + id: "m-1", + email: "someone-else@example.com", + }); + + await expect(service.accept("tok", "m-1")).rejects.toBeInstanceOf( + ForbiddenException, + ); + }); + + it("returns 410 (Gone) and marks EXPIRED for an expired invite", async () => { + prisma.invitation.findUnique.mockResolvedValue({ + ...pending, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect(service.accept("tok", "m-1")).rejects.toBeInstanceOf( + GoneException, + ); + expect(prisma.invitation.update).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ status: "EXPIRED" }), + }), + ); + }); + + it("rejects a non-pending invitation", async () => { + prisma.invitation.findUnique.mockResolvedValue({ + ...pending, + status: "ACCEPTED", + }); + + await expect(service.accept("tok", "m-1")).rejects.toBeInstanceOf( + ConflictException, + ); + }); + }); +}); diff --git a/src/invitation/invitation.service.ts b/src/invitation/invitation.service.ts new file mode 100644 index 0000000..131d776 --- /dev/null +++ b/src/invitation/invitation.service.ts @@ -0,0 +1,283 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + GoneException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { randomBytes } from "node:crypto"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; +import { withId } from "src/common/utils/db.util"; +import { RoleScope, InvitationStatus } from "../../generated/prisma/enums"; +import { MailService } from "./mail.service"; +import { CreateInvitationDto } from "./dto/invitation.dto"; + +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days (KKTIX behaviour) + +interface InviteScope { + scopeType: RoleScope; + organizationId?: string; + activityId?: string; +} + +@Injectable() +export class InvitationService { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + private readonly mail: MailService, + ) {} + + inviteToOrg(orgId: string, dto: CreateInvitationDto, invitedById: string) { + return this.invite( + { scopeType: RoleScope.ORG, organizationId: orgId }, + dto, + invitedById, + ); + } + + inviteToActivity( + activityId: string, + dto: CreateInvitationDto, + invitedById: string, + ) { + return this.invite( + { scopeType: RoleScope.EVENT, activityId }, + dto, + invitedById, + ); + } + + listForOrg(organizationId: string) { + return this.prisma.invitation.findMany({ + where: { scopeType: RoleScope.ORG, organizationId }, + orderBy: { createdAt: "desc" }, + }); + } + + listForActivity(activityId: string) { + return this.prisma.invitation.findMany({ + where: { scopeType: RoleScope.EVENT, activityId }, + orderBy: { createdAt: "desc" }, + }); + } + + private async invite( + scope: InviteScope, + dto: CreateInvitationDto, + invitedById: string, + ) { + await this.assertScopeAndRole(scope, dto.roleId); + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS); + + const invitation = await this.prisma.invitation.create({ + data: withId({ + email: dto.email.toLowerCase(), + token, + status: InvitationStatus.PENDING, + scopeType: scope.scopeType, + organizationId: scope.organizationId ?? null, + activityId: scope.activityId ?? null, + roleId: dto.roleId, + invitedById, + expiresAt, + }), + }); + + await this.mail.sendInvitation({ + to: invitation.email, + scopeLabel: await this.scopeLabel(scope), + acceptUrl: this.acceptUrl(token), + expiresAt, + }); + + return invitation; + } + + /** Accept an invitation. The caller's email must match the invitee. */ + async accept(token: string, memberId: string) { + const invitation = await this.loadPending(token); + const member = await this.loadMember(memberId); + this.assertRecipient(invitation.email, member.email); + + const existing = await this.prisma.membership.findFirst({ + where: { + memberId, + roleId: invitation.roleId, + scopeType: invitation.scopeType, + organizationId: invitation.organizationId, + activityId: invitation.activityId, + }, + }); + + await this.prisma.$transaction(async (tx) => { + if (!existing) { + await tx.membership.create({ + data: withId({ + memberId, + roleId: invitation.roleId, + scopeType: invitation.scopeType, + organizationId: invitation.organizationId, + activityId: invitation.activityId, + }), + }); + } + await tx.invitation.update({ + where: { id: invitation.id }, + data: { status: InvitationStatus.ACCEPTED, respondedAt: new Date() }, + }); + }); + + // New permissions → refresh the accepting member's session. + await this.authService.bumpTokenVersion(memberId); + return { ok: true }; + } + + async reject(token: string, memberId: string) { + const invitation = await this.loadPending(token); + const member = await this.loadMember(memberId); + this.assertRecipient(invitation.email, member.email); + await this.prisma.invitation.update({ + where: { id: invitation.id }, + data: { status: InvitationStatus.REJECTED, respondedAt: new Date() }, + }); + return { ok: true }; + } + + private async loadMember(memberId: string) { + const member = await this.prisma.member.findUnique({ + where: { id: memberId }, + select: { id: true, email: true }, + }); + if (!member) throw new NotFoundException("Member not found"); + return member; + } + + /** Re-send a pending/expired invitation with a fresh token and 7-day window. */ + async resend(id: string) { + const invitation = await this.prisma.invitation.findUnique({ + where: { id }, + }); + if (!invitation) throw new NotFoundException("Invitation not found"); + if (invitation.status === InvitationStatus.ACCEPTED) { + throw new ConflictException("Invitation has already been accepted"); + } + + const token = randomBytes(32).toString("hex"); + const expiresAt = new Date(Date.now() + INVITE_TTL_MS); + const updated = await this.prisma.invitation.update({ + where: { id }, + data: { + token, + expiresAt, + status: InvitationStatus.PENDING, + respondedAt: null, + }, + }); + + await this.mail.sendInvitation({ + to: updated.email, + scopeLabel: await this.scopeLabel(updated), + acceptUrl: this.acceptUrl(token), + expiresAt, + }); + return updated; + } + + async revoke(id: string) { + const invitation = await this.prisma.invitation.findUnique({ + where: { id }, + }); + if (!invitation) throw new NotFoundException("Invitation not found"); + await this.prisma.invitation.delete({ where: { id } }); + return { ok: true }; + } + + /** Loads a token, enforcing the PENDING + not-expired state machine. */ + private async loadPending(token: string) { + const invitation = await this.prisma.invitation.findUnique({ + where: { token }, + }); + if (!invitation) throw new NotFoundException("Invitation not found"); + + if (invitation.status !== InvitationStatus.PENDING) { + throw new ConflictException( + `Invitation has already been ${invitation.status.toLowerCase()}`, + ); + } + if (invitation.expiresAt.getTime() < Date.now()) { + await this.prisma.invitation.update({ + where: { id: invitation.id }, + data: { status: InvitationStatus.EXPIRED }, + }); + throw new GoneException("Invitation has expired"); + } + return invitation; + } + + private assertRecipient(invitedEmail: string, userEmail: string) { + if (invitedEmail.toLowerCase() !== userEmail.toLowerCase()) { + throw new ForbiddenException( + "This invitation was issued to a different email address", + ); + } + } + + private async assertScopeAndRole(scope: InviteScope, roleId: string) { + if (scope.scopeType === RoleScope.ORG) { + const org = await this.prisma.organization.findUnique({ + where: { id: scope.organizationId }, + select: { id: true }, + }); + if (!org) throw new NotFoundException("Organization not found"); + } else if (scope.scopeType === RoleScope.EVENT) { + const activity = await this.prisma.activity.findUnique({ + where: { id: scope.activityId }, + select: { id: true }, + }); + if (!activity) throw new NotFoundException("Activity not found"); + } + + const role = await this.prisma.role.findUnique({ + where: { id: roleId }, + select: { scope: true }, + }); + if (!role) throw new NotFoundException("Role not found"); + if (role.scope !== scope.scopeType) { + throw new BadRequestException( + `Role must be ${scope.scopeType}-scoped for this invitation`, + ); + } + } + + private async scopeLabel(scope: { + scopeType: RoleScope; + organizationId?: string | null; + activityId?: string | null; + }): Promise { + if (scope.scopeType === RoleScope.ORG && scope.organizationId) { + const org = await this.prisma.organization.findUnique({ + where: { id: scope.organizationId }, + select: { name: true }, + }); + return `organization ${org?.name ?? scope.organizationId}`; + } + if (scope.scopeType === RoleScope.EVENT && scope.activityId) { + const activity = await this.prisma.activity.findUnique({ + where: { id: scope.activityId }, + select: { slug: true }, + }); + return `activity ${activity?.slug ?? scope.activityId}`; + } + return "the platform"; + } + + private acceptUrl(token: string): string { + const base = process.env.CLIENT_URL || "http://localhost:3000"; + return `${base}/invitations/accept?token=${token}`; + } +} diff --git a/src/invitation/mail.service.ts b/src/invitation/mail.service.ts new file mode 100644 index 0000000..2dbbceb --- /dev/null +++ b/src/invitation/mail.service.ts @@ -0,0 +1,26 @@ +import { Injectable, Logger } from "@nestjs/common"; + +export interface InvitationEmail { + to: string; + scopeLabel: string; // e.g. "organization JSDC" / "activity jsdc-2026" + acceptUrl: string; + expiresAt: Date; +} + +/** + * Stub mailer. Logs the invitation instead of sending a real email. + * Swap the implementation for a real provider (SES/SendGrid/SMTP) later; + * callers depend only on `sendInvitation`. + */ +@Injectable() +export class MailService { + private readonly logger = new Logger(MailService.name); + + async sendInvitation(email: InvitationEmail): Promise { + this.logger.log( + `[invitation] to=${email.to} scope="${email.scopeLabel}" ` + + `expires=${email.expiresAt.toISOString()} url=${email.acceptUrl}`, + ); + await Promise.resolve(); + } +} diff --git a/src/main.ts b/src/main.ts index 1c36e3f..8937a6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,12 +6,18 @@ import cookieParser from "cookie-parser"; import { ValidationPipe } from "@nestjs/common"; import { JwtAuthGuard } from "./auth/guards/jwt-auth.guard"; import { PermissionGuard } from "./auth/guards/permission.guard"; +import { PrismaService } from "./prisma/prisma.service"; +import { PermissionsCacheService } from "./auth/services/permissions-cache.service"; +import express from "express"; +import { join } from "node:path"; async function bootstrap() { const app = await NestFactory.create(AppModule); app.use(cookieParser()); + app.use("/ui", express.static(join(process.cwd(), "public"))); + app.enableCors({ origin: process.env.CLIENT_URL || "http://localhost:3000", credentials: true, @@ -36,9 +42,11 @@ async function bootstrap() { ); const reflector = app.get(Reflector); + const prisma = app.get(PrismaService); + const permissionsCache = app.get(PermissionsCacheService); app.useGlobalGuards( new JwtAuthGuard(reflector), - new PermissionGuard(reflector), + new PermissionGuard(reflector, prisma, permissionsCache), ); await app.listen(process.env.PORT ?? 4000); diff --git a/src/member/dto/member.dto.ts b/src/member/dto/member.dto.ts new file mode 100644 index 0000000..19f1dcb --- /dev/null +++ b/src/member/dto/member.dto.ts @@ -0,0 +1,43 @@ +import { + ApiProperty, + ApiPropertyOptional, + PartialType, +} from "@nestjs/swagger"; +import { + IsOptional, + IsString, + IsEmail, + IsEnum, + MaxLength, +} from "class-validator"; +import { MemberStatus } from "../../../generated/prisma/enums"; + +export class CreateMemberDto { + @ApiProperty({ example: "user@example.com" }) + @IsEmail() + email: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + displayName?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + location?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + jobTitle?: string; + + @ApiPropertyOptional({ enum: MemberStatus }) + @IsOptional() + @IsEnum(MemberStatus) + status?: MemberStatus; +} + +export class UpdateMemberDto extends PartialType(CreateMemberDto) {} diff --git a/src/member/member.controller.ts b/src/member/member.controller.ts new file mode 100644 index 0000000..5c84693 --- /dev/null +++ b/src/member/member.controller.ts @@ -0,0 +1,75 @@ +import { + Controller, + Get, + Post, + Body, + Param, + ParseUUIDPipe, + Patch, + Delete, +} from "@nestjs/common"; +import { ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { MemberService } from "./member.service"; +import { CreateMemberDto, UpdateMemberDto } from "./dto/member.dto"; +import { Permissions } from "../auth/decorators/permissions.decorator"; +import { RequirePermissions } from "../auth/decorators/scoped-permissions.decorator"; + +@ApiTags("Members") +@Controller("members") +export class MemberController { + constructor(private readonly memberService: MemberService) {} + + @Permissions("permission:manage") + @Get() + @ApiOperation({ summary: "Get all members" }) + async list() { + return this.memberService.list(); + } + + @Permissions("permission:manage") + @Post() + @ApiOperation({ summary: "Create new member" }) + @ApiResponse({ status: 201, description: "Created successfully" }) + @ApiResponse({ status: 400, description: "Invalid input or validation failed" }) + @ApiResponse({ status: 409, description: "Email already exists" }) + async create(@Body() dto: CreateMemberDto) { + return this.memberService.create(dto); + } + + @RequirePermissions({ scope: "ORG", param: "orgId", perms: ["org:member:manage"] }) + @Get("organization/:orgId") + @ApiOperation({ summary: "List all members of an organization (with roles)" }) + async listByOrganization(@Param("orgId", ParseUUIDPipe) orgId: string) { + return this.memberService.listByOrganization(orgId); + } + + @Permissions("permission:manage") + @Get(":id") + @ApiOperation({ summary: "Get member by ID" }) + @ApiResponse({ status: 404, description: "Member not found" }) + async get(@Param("id", ParseUUIDPipe) id: string) { + return this.memberService.get(id); + } + + @Permissions("permission:manage") + @Patch(":id") + @ApiOperation({ summary: "Update member" }) + @ApiResponse({ status: 200, description: "Member updated successfully" }) + @ApiResponse({ status: 404, description: "Member not found" }) + @ApiResponse({ status: 409, description: "Email already exists" }) + async update( + @Param("id", ParseUUIDPipe) id: string, + @Body() dto: UpdateMemberDto, + ) { + return this.memberService.update(id, dto); + } + + @Permissions("permission:manage") + @Delete(":id") + @ApiOperation({ summary: "Delete member" }) + @ApiResponse({ status: 200, description: "Member deleted successfully" }) + @ApiResponse({ status: 404, description: "Member not found" }) + async remove(@Param("id", ParseUUIDPipe) id: string) { + return this.memberService.remove(id); + } +} diff --git a/src/member/member.module.ts b/src/member/member.module.ts new file mode 100644 index 0000000..fa2e10d --- /dev/null +++ b/src/member/member.module.ts @@ -0,0 +1,9 @@ +import { Module } from "@nestjs/common"; +import { MemberController } from "./member.controller"; +import { MemberService } from "./member.service"; + +@Module({ + controllers: [MemberController], + providers: [MemberService], +}) +export class MemberModule {} diff --git a/src/member/member.service.ts b/src/member/member.service.ts new file mode 100644 index 0000000..512687d --- /dev/null +++ b/src/member/member.service.ts @@ -0,0 +1,133 @@ +import { + Injectable, + ConflictException, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { CreateMemberDto, UpdateMemberDto } from "./dto/member.dto"; +import { withId } from "src/common/utils/db.util"; +import { RoleScope } from "../../generated/prisma/enums"; + +@Injectable() +export class MemberService { + constructor(private prisma: PrismaService) {} + + async list() { + return this.prisma.member.findMany({ + orderBy: { createdAt: "desc" }, + }); + } + + async get(id: string) { + const member = await this.prisma.member.findUnique({ where: { id } }); + if (!member) throw new NotFoundException("Member not found"); + return member; + } + + /** 列出組織的所有成員(包含角色) */ + async listByOrganization(organizationId: string) { + const memberships = await this.prisma.membership.findMany({ + where: { + scopeType: RoleScope.ORG, + organizationId, + }, + include: { + member: { + select: { + id: true, + email: true, + displayName: true, + avatarUrl: true, + createdAt: true, + }, + }, + role: { + select: { + id: true, + name: true, + scope: true, + }, + }, + }, + orderBy: { member: { email: "asc" } }, + }); + + // 按 memberId 分組合併角色 + const memberMap = new Map(); + for (const m of memberships) { + if (!memberMap.has(m.memberId)) { + memberMap.set(m.memberId, { + member: m.member, + roles: [], + }); + } + memberMap.get(m.memberId).roles.push(m.role); + } + + return Array.from(memberMap.values()); + } + + async create(dto: CreateMemberDto) { + await this.checkEmailExists(dto.email); + + return this.prisma.member.create({ + data: withId({ + email: dto.email, + displayName: dto.displayName ?? null, + location: dto.location ?? null, + jobTitle: dto.jobTitle ?? null, + ...(dto.status && { status: dto.status }), + }), + }); + } + + async update(id: string, dto: UpdateMemberDto) { + // Ensure member exists + await this.get(id); + + // If email is being changed, ensure it's still unique + if (dto.email) { + const existing = await this.prisma.member.findUnique({ + where: { email: dto.email }, + }); + if (existing && existing.id !== id) { + throw new ConflictException( + `Member with email "${dto.email}" already exists`, + ); + } + } + + return this.prisma.member.update({ + where: { id }, + data: { + ...(dto.email && { email: dto.email }), + ...(dto.displayName !== undefined && { displayName: dto.displayName }), + ...(dto.location !== undefined && { location: dto.location }), + ...(dto.jobTitle !== undefined && { jobTitle: dto.jobTitle }), + ...(dto.status && { status: dto.status }), + }, + }); + } + + async remove(id: string) { + // Ensure member exists + await this.get(id); + + await this.prisma.member.delete({ where: { id } }); + return { id }; + } + + // ========== Private Methods ========== + + private async checkEmailExists(email: string): Promise { + const existing = await this.prisma.member.findUnique({ + where: { email }, + }); + + if (existing) { + throw new ConflictException( + `Member with email "${email}" already exists`, + ); + } + } +} diff --git a/src/organization/dto/organization.dto.ts b/src/organization/dto/organization.dto.ts new file mode 100644 index 0000000..8f99180 --- /dev/null +++ b/src/organization/dto/organization.dto.ts @@ -0,0 +1,24 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { IsOptional, IsString, Matches, MaxLength } from "class-validator"; + +export class CreateOrganizationDto { + @ApiProperty({ example: "JSDC" }) + @IsString() + @MaxLength(255) + name: string; + + @ApiProperty({ example: "jsdc" }) + @IsString() + @MaxLength(255) + @Matches(/^[a-z0-9-]+$/, { + message: "slug must be lowercase letters, numbers and hyphens", + }) + slug: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; +} + +export class UpdateOrganizationDto extends PartialType(CreateOrganizationDto) {} diff --git a/src/organization/organization.controller.ts b/src/organization/organization.controller.ts new file mode 100644 index 0000000..f630d7a --- /dev/null +++ b/src/organization/organization.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Req, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import type { Request } from "express"; +import { OrganizationService } from "./organization.service"; +import { + CreateOrganizationDto, + UpdateOrganizationDto, +} from "./dto/organization.dto"; +import { RequirePermissions } from "../auth/decorators/scoped-permissions.decorator"; +import type { AuthUser } from "../auth/strategies/jwt.strategy"; + +@ApiTags("Organizations") +@Controller("organizations") +export class OrganizationController { + constructor(private readonly organizationService: OrganizationService) {} + + // Any authenticated user can create an organization (becomes its Owner). + @Post() + create(@Body() dto: CreateOrganizationDto, @Req() req: Request) { + const user = req.user as AuthUser; + return this.organizationService.create(dto, user.id); + } + + // Organizations the current user belongs to. + @Get() + listMine(@Req() req: Request) { + const user = req.user as AuthUser; + return this.organizationService.listForMember(user.id); + } + + @RequirePermissions({ scope: "ORG", param: "id", perms: ["org:profile"] }) + @Get(":id") + get(@Param("id", new ParseUUIDPipe()) id: string) { + return this.organizationService.get(id); + } + + @RequirePermissions({ scope: "ORG", param: "id", perms: ["org:profile"] }) + @Patch(":id") + update( + @Param("id", new ParseUUIDPipe()) id: string, + @Body() dto: UpdateOrganizationDto, + ) { + return this.organizationService.update(id, dto); + } + + @RequirePermissions({ scope: "ORG", param: "id", perms: ["org:profile"] }) + @Delete(":id") + remove(@Param("id", new ParseUUIDPipe()) id: string) { + return this.organizationService.remove(id); + } +} diff --git a/src/organization/organization.module.ts b/src/organization/organization.module.ts new file mode 100644 index 0000000..8067cf2 --- /dev/null +++ b/src/organization/organization.module.ts @@ -0,0 +1,12 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { OrganizationController } from "./organization.controller"; +import { OrganizationService } from "./organization.service"; + +@Module({ + imports: [AuthModule], + controllers: [OrganizationController], + providers: [OrganizationService], + exports: [OrganizationService], +}) +export class OrganizationModule {} diff --git a/src/organization/organization.service.spec.ts b/src/organization/organization.service.spec.ts new file mode 100644 index 0000000..eced43a --- /dev/null +++ b/src/organization/organization.service.spec.ts @@ -0,0 +1,77 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({})), + generateState: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { OrganizationService } from "./organization.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; + +describe("OrganizationService", () => { + let service: OrganizationService; + let prisma: any; + let authService: jest.Mocked; + + beforeEach(async () => { + prisma = { + organization: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findMany: jest.fn(), + }, + role: { findUnique: jest.fn() }, + }; + authService = { + bumpTokenVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + OrganizationService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: authService }, + ], + }).compile(); + + service = module.get(OrganizationService); + }); + + describe("create", () => { + it("creates the org with the creator as Owner and refreshes their session", async () => { + prisma.organization.findUnique.mockResolvedValue(null); + prisma.role.findUnique.mockResolvedValue({ id: "owner-role" }); + prisma.organization.create.mockResolvedValue({ id: "org-1" }); + + await service.create({ name: "JSDC", slug: "jsdc" }, "creator-1"); + + const createArg = prisma.organization.create.mock.calls[0][0]; + expect(createArg.data.memberships.create).toEqual( + expect.objectContaining({ + memberId: "creator-1", + roleId: "owner-role", + scopeType: "ORG", + }), + ); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("creator-1"); + }); + + it("rejects a duplicate slug", async () => { + prisma.organization.findUnique.mockResolvedValue({ id: "existing" }); + await expect( + service.create({ name: "X", slug: "taken" }, "creator-1"), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("fails clearly when the Owner role is not seeded", async () => { + prisma.organization.findUnique.mockResolvedValue(null); + prisma.role.findUnique.mockResolvedValue(null); + await expect( + service.create({ name: "X", slug: "x" }, "creator-1"), + ).rejects.toBeInstanceOf(NotFoundException); + }); + }); +}); diff --git a/src/organization/organization.service.ts b/src/organization/organization.service.ts new file mode 100644 index 0000000..29bbe2b --- /dev/null +++ b/src/organization/organization.service.ts @@ -0,0 +1,95 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; +import { withId } from "src/common/utils/db.util"; +import { RoleScope } from "../../generated/prisma/enums"; +import { + CreateOrganizationDto, + UpdateOrganizationDto, +} from "./dto/organization.dto"; + +export const ORG_OWNER_ROLE = "Owner"; + +@Injectable() +export class OrganizationService { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + ) {} + + /** Organizations the member belongs to (any role). */ + listForMember(memberId: string) { + return this.prisma.organization.findMany({ + where: { memberships: { some: { memberId } } }, + orderBy: { name: "asc" }, + }); + } + + async get(id: string) { + const org = await this.prisma.organization.findUnique({ where: { id } }); + if (!org) throw new NotFoundException("Organization not found"); + return org; + } + + /** Create an organization; the creator becomes its Owner. */ + async create(dto: CreateOrganizationDto, creatorId: string) { + const existing = await this.prisma.organization.findUnique({ + where: { slug: dto.slug }, + }); + if (existing) { + throw new ConflictException(`slug '${dto.slug}' is already taken`); + } + + const ownerRole = await this.prisma.role.findUnique({ + where: { name_scope: { name: ORG_OWNER_ROLE, scope: RoleScope.ORG } }, + select: { id: true }, + }); + if (!ownerRole) { + throw new NotFoundException( + "Org Owner role is missing — run the database seed", + ); + } + + const org = await this.prisma.organization.create({ + data: withId({ + name: dto.name, + slug: dto.slug, + description: dto.description, + memberships: { + create: withId({ + memberId: creatorId, + roleId: ownerRole.id, + scopeType: RoleScope.ORG, + }), + }, + }), + }); + + // Creator just gained org-scoped permissions → refresh their session. + await this.authService.bumpTokenVersion(creatorId); + return org; + } + + async update(id: string, dto: UpdateOrganizationDto) { + await this.get(id); + if (dto.slug) { + const clash = await this.prisma.organization.findUnique({ + where: { slug: dto.slug }, + }); + if (clash && clash.id !== id) { + throw new ConflictException(`slug '${dto.slug}' is already taken`); + } + } + return this.prisma.organization.update({ where: { id }, data: dto }); + } + + async remove(id: string) { + await this.get(id); + await this.prisma.organization.delete({ where: { id } }); + return { ok: true }; + } +} diff --git a/src/rbac/dto/permission.dto.ts b/src/rbac/dto/permission.dto.ts new file mode 100644 index 0000000..d6c3e43 --- /dev/null +++ b/src/rbac/dto/permission.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { IsString, IsOptional, Matches, MaxLength } from "class-validator"; + +export class CreatePermissionDto { + @ApiProperty({ example: "activity:manage" }) + @IsString() + @MaxLength(100) + @Matches(/^[a-z0-9_-]+:[a-z0-9_-]+$/, { + message: "Permission code must look like 'resource:action'", + }) + code: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; +} + +export class UpdatePermissionDto extends PartialType(CreatePermissionDto) {} diff --git a/src/rbac/dto/role.dto.ts b/src/rbac/dto/role.dto.ts new file mode 100644 index 0000000..2ee4b5e --- /dev/null +++ b/src/rbac/dto/role.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from "@nestjs/swagger"; +import { + IsArray, + IsEnum, + IsOptional, + IsString, + IsUUID, + MaxLength, +} from "class-validator"; +import { RoleScope } from "../../../generated/prisma/enums"; + +export class CreateRoleDto { + @ApiProperty({ example: "editor" }) + @IsString() + @MaxLength(50) + name: string; + + @ApiPropertyOptional({ enum: RoleScope, default: RoleScope.PLATFORM }) + @IsOptional() + @IsEnum(RoleScope) + scope?: RoleScope; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(255) + description?: string; + + @ApiProperty({ type: [String], description: "Permission IDs" }) + @IsArray() + @IsUUID("all", { each: true }) + permissionIds: string[]; +} + +export class UpdateRoleDto extends PartialType(CreateRoleDto) {} + +export class AssignRolesDto { + @ApiProperty({ type: [String], description: "Role IDs to assign to member" }) + @IsArray() + @IsUUID("all", { each: true }) + roleIds: string[]; +} diff --git a/src/rbac/membership.controller.ts b/src/rbac/membership.controller.ts new file mode 100644 index 0000000..b2944e7 --- /dev/null +++ b/src/rbac/membership.controller.ts @@ -0,0 +1,82 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Put, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { RequirePermissions } from "../auth/decorators/scoped-permissions.decorator"; +import { MembershipService } from "./membership.service"; +import { AssignRolesDto } from "./dto/role.dto"; + +@ApiTags("Organization Members") +@Controller("organizations/:orgId/members/:memberId/roles") +export class OrganizationMemberController { + constructor(private readonly membershipService: MembershipService) {} + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Get() + list( + @Param("orgId", new ParseUUIDPipe()) orgId: string, + @Param("memberId", new ParseUUIDPipe()) memberId: string, + ) { + return this.membershipService.listForOrg(orgId, memberId); + } + + @RequirePermissions({ + scope: "ORG", + param: "orgId", + perms: ["org:member:manage"], + }) + @Put() + replace( + @Param("orgId", new ParseUUIDPipe()) orgId: string, + @Param("memberId", new ParseUUIDPipe()) memberId: string, + @Body() dto: AssignRolesDto, + ) { + return this.membershipService.replaceForOrg(orgId, memberId, dto.roleIds); + } +} + +@ApiTags("Activity Members") +@Controller("activities/:activityId/members/:memberId/roles") +export class ActivityMemberController { + constructor(private readonly membershipService: MembershipService) {} + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Get() + list( + @Param("activityId", new ParseUUIDPipe()) activityId: string, + @Param("memberId", new ParseUUIDPipe()) memberId: string, + ) { + return this.membershipService.listForActivity(activityId, memberId); + } + + @RequirePermissions({ + scope: "EVENT", + param: "activityId", + perms: ["event:member:manage"], + }) + @Put() + replace( + @Param("activityId", new ParseUUIDPipe()) activityId: string, + @Param("memberId", new ParseUUIDPipe()) memberId: string, + @Body() dto: AssignRolesDto, + ) { + return this.membershipService.replaceForActivity( + activityId, + memberId, + dto.roleIds, + ); + } +} diff --git a/src/rbac/membership.service.spec.ts b/src/rbac/membership.service.spec.ts new file mode 100644 index 0000000..9ca8bac --- /dev/null +++ b/src/rbac/membership.service.spec.ts @@ -0,0 +1,79 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({})), + generateState: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { BadRequestException, NotFoundException } from "@nestjs/common"; +import { MembershipService } from "./membership.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; + +describe("MembershipService", () => { + let service: MembershipService; + let prisma: any; + let authService: jest.Mocked; + + beforeEach(async () => { + prisma = { + member: { findUnique: jest.fn().mockResolvedValue({ id: "m-1" }) }, + organization: { + findUnique: jest.fn().mockResolvedValue({ id: "org-1" }), + }, + activity: { findUnique: jest.fn().mockResolvedValue({ id: "act-1" }) }, + role: { findMany: jest.fn() }, + membership: { + findMany: jest.fn().mockResolvedValue([]), + deleteMany: jest.fn(), + createMany: jest.fn(), + }, + $transaction: jest.fn((fn: any) => fn(prisma)), + }; + authService = { + bumpTokenVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MembershipService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: authService }, + ], + }).compile(); + + service = module.get(MembershipService); + }); + + it("replaces org roles, scoping the write and bumping the member", async () => { + prisma.role.findMany.mockResolvedValue([{ id: "r-1", scope: "ORG" }]); + + await service.replaceForOrg("org-1", "m-1", ["r-1"]); + + expect(prisma.membership.deleteMany).toHaveBeenCalledWith({ + where: { + memberId: "m-1", + scopeType: "ORG", + organizationId: "org-1", + activityId: null, + }, + }); + expect(prisma.membership.createMany).toHaveBeenCalled(); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-1"); + }); + + it("rejects roles whose scope does not match the target scope", async () => { + prisma.role.findMany.mockResolvedValue([{ id: "r-1", scope: "EVENT" }]); + + await expect( + service.replaceForOrg("org-1", "m-1", ["r-1"]), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("rejects unknown role ids", async () => { + prisma.role.findMany.mockResolvedValue([]); // none found + + await expect( + service.replaceForActivity("act-1", "m-1", ["missing"]), + ).rejects.toBeInstanceOf(NotFoundException); + }); +}); diff --git a/src/rbac/membership.service.ts b/src/rbac/membership.service.ts new file mode 100644 index 0000000..637d1dc --- /dev/null +++ b/src/rbac/membership.service.ts @@ -0,0 +1,157 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; +import { withId } from "src/common/utils/db.util"; +import { RoleScope } from "../../generated/prisma/enums"; + +/** + * Manages scoped role bindings (memberships). A member can hold different roles + * in different organizations / activities. Replacing roles bumps the member's + * tokenVersion so any live session re-resolves permissions immediately. + */ +@Injectable() +export class MembershipService { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + ) {} + + listForOrg(organizationId: string, memberId: string) { + return this.list({ scopeType: RoleScope.ORG, organizationId, memberId }); + } + + async replaceForOrg( + organizationId: string, + memberId: string, + roleIds: string[], + ) { + await this.assertOrganization(organizationId); + return this.replace({ + scopeType: RoleScope.ORG, + organizationId, + memberId, + roleIds, + }); + } + + listForActivity(activityId: string, memberId: string) { + return this.list({ scopeType: RoleScope.EVENT, activityId, memberId }); + } + + async replaceForActivity( + activityId: string, + memberId: string, + roleIds: string[], + ) { + await this.assertActivity(activityId); + return this.replace({ + scopeType: RoleScope.EVENT, + activityId, + memberId, + roleIds, + }); + } + + private list(scope: { + scopeType: RoleScope; + memberId: string; + organizationId?: string; + activityId?: string; + }) { + return this.prisma.membership.findMany({ + where: { + memberId: scope.memberId, + scopeType: scope.scopeType, + organizationId: scope.organizationId ?? null, + activityId: scope.activityId ?? null, + }, + include: { role: true }, + }); + } + + private async replace(scope: { + scopeType: RoleScope; + memberId: string; + organizationId?: string; + activityId?: string; + roleIds: string[]; + }) { + await this.assertMember(scope.memberId); + await this.assertRolesMatchScope(scope.roleIds, scope.scopeType); + + const organizationId = scope.organizationId ?? null; + const activityId = scope.activityId ?? null; + + await this.prisma.$transaction(async (tx) => { + await tx.membership.deleteMany({ + where: { + memberId: scope.memberId, + scopeType: scope.scopeType, + organizationId, + activityId, + }, + }); + if (scope.roleIds.length > 0) { + await tx.membership.createMany({ + data: scope.roleIds.map((roleId) => + withId({ + memberId: scope.memberId, + roleId, + scopeType: scope.scopeType, + organizationId, + activityId, + }), + ), + }); + } + }); + + await this.authService.bumpTokenVersion(scope.memberId); + return this.list(scope); + } + + private async assertRolesMatchScope(roleIds: string[], scopeType: RoleScope) { + if (roleIds.length === 0) return; + const roles = await this.prisma.role.findMany({ + where: { id: { in: roleIds } }, + select: { id: true, scope: true }, + }); + if (roles.length !== roleIds.length) { + throw new NotFoundException("One or more roleIds are invalid"); + } + const mismatch = roles.filter((r) => r.scope !== scopeType); + if (mismatch.length > 0) { + throw new BadRequestException( + `All roles must be ${scopeType}-scoped for this assignment`, + ); + } + } + + private async assertMember(memberId: string) { + const member = await this.prisma.member.findUnique({ + where: { id: memberId }, + select: { id: true }, + }); + if (!member) throw new NotFoundException("Member not found"); + } + + private async assertOrganization(organizationId: string) { + const org = await this.prisma.organization.findUnique({ + where: { id: organizationId }, + select: { id: true }, + }); + if (!org) throw new NotFoundException("Organization not found"); + } + + private async assertActivity(activityId: string) { + const activity = await this.prisma.activity.findUnique({ + where: { id: activityId }, + select: { id: true }, + }); + if (!activity) throw new NotFoundException("Activity not found"); + } +} diff --git a/src/rbac/permission.controller.ts b/src/rbac/permission.controller.ts new file mode 100644 index 0000000..1f2973e --- /dev/null +++ b/src/rbac/permission.controller.ts @@ -0,0 +1,47 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, +} from "@nestjs/common"; +import { ApiTags } from "@nestjs/swagger"; +import { Permissions } from "../auth/decorators/permissions.decorator"; +import { PermissionService } from "./permission.service"; +import { CreatePermissionDto, UpdatePermissionDto } from "./dto/permission.dto"; + +@ApiTags("Permissions") +@Controller("permissions") +export class PermissionController { + constructor(private readonly permissionService: PermissionService) {} + + @Permissions("permission:manage") + @Get() + list() { + return this.permissionService.list(); + } + + @Permissions("permission:manage") + @Post() + create(@Body() dto: CreatePermissionDto) { + return this.permissionService.create(dto); + } + + @Permissions("permission:manage") + @Patch(":id") + update( + @Param("id", new ParseUUIDPipe()) id: string, + @Body() dto: UpdatePermissionDto, + ) { + return this.permissionService.update(id, dto); + } + + @Permissions("permission:manage") + @Delete(":id") + remove(@Param("id", new ParseUUIDPipe()) id: string) { + return this.permissionService.delete(id); + } +} diff --git a/src/rbac/permission.service.spec.ts b/src/rbac/permission.service.spec.ts new file mode 100644 index 0000000..0e83be4 --- /dev/null +++ b/src/rbac/permission.service.spec.ts @@ -0,0 +1,66 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({})), + generateState: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { ConflictException } from "@nestjs/common"; +import { PermissionService } from "./permission.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; + +describe("PermissionService", () => { + let service: PermissionService; + let prisma: any; + let authService: jest.Mocked; + + beforeEach(async () => { + prisma = { + permission: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + member: { findMany: jest.fn() }, + }; + const authMock = { + bumpTokenVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PermissionService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: authMock }, + ], + }).compile(); + + service = module.get(PermissionService); + authService = module.get(AuthService); + }); + + it("rejects duplicate code", async () => { + prisma.permission.findUnique.mockResolvedValue({ id: "p-1" }); + await expect(service.create({ code: "x:y" })).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it("creates permission with new code", async () => { + prisma.permission.findUnique.mockResolvedValue(null); + prisma.permission.create.mockResolvedValue({ id: "p-1", code: "x:y" }); + const result = await service.create({ code: "x:y" }); + expect(result.code).toBe("x:y"); + }); + + it("delete bumps tokenVersion of every member with the permission", async () => { + prisma.permission.findUnique.mockResolvedValue({ id: "p-1" }); + prisma.member.findMany.mockResolvedValue([{ id: "m-1" }, { id: "m-2" }]); + await service.delete("p-1"); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-1"); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-2"); + expect(prisma.permission.delete).toHaveBeenCalled(); + }); +}); diff --git a/src/rbac/permission.service.ts b/src/rbac/permission.service.ts new file mode 100644 index 0000000..852049f --- /dev/null +++ b/src/rbac/permission.service.ts @@ -0,0 +1,76 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { withId } from "src/common/utils/db.util"; +import { AuthService } from "../auth/auth.service"; +import { CreatePermissionDto, UpdatePermissionDto } from "./dto/permission.dto"; + +@Injectable() +export class PermissionService { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + ) {} + + list() { + return this.prisma.permission.findMany({ orderBy: { code: "asc" } }); + } + + async create(dto: CreatePermissionDto) { + const existing = await this.prisma.permission.findUnique({ + where: { code: dto.code }, + }); + if (existing) { + throw new ConflictException(`Permission '${dto.code}' already exists`); + } + return this.prisma.permission.create({ data: withId(dto) }); + } + + async update(id: string, dto: UpdatePermissionDto) { + const permission = await this.prisma.permission.findUnique({ + where: { id }, + }); + if (!permission) throw new NotFoundException("Permission not found"); + + const updated = await this.prisma.permission.update({ + where: { id }, + data: dto, + }); + + // If code changed, affected members' cached permissions are now stale. + if (dto.code && dto.code !== permission.code) { + await this.bumpMembersWithPermission(id); + } + return updated; + } + + async delete(id: string) { + const permission = await this.prisma.permission.findUnique({ + where: { id }, + }); + if (!permission) throw new NotFoundException("Permission not found"); + + await this.bumpMembersWithPermission(id); + await this.prisma.permission.delete({ where: { id } }); + return { ok: true }; + } + + private async bumpMembersWithPermission(permissionId: string) { + const members = await this.prisma.member.findMany({ + where: { + memberships: { + some: { + role: { permissions: { some: { permission_id: permissionId } } }, + }, + }, + }, + select: { id: true }, + }); + await Promise.all( + members.map((m) => this.authService.bumpTokenVersion(m.id)), + ); + } +} diff --git a/src/rbac/rbac.module.ts b/src/rbac/rbac.module.ts new file mode 100644 index 0000000..481e633 --- /dev/null +++ b/src/rbac/rbac.module.ts @@ -0,0 +1,24 @@ +import { Module } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module"; +import { PermissionController } from "./permission.controller"; +import { PermissionService } from "./permission.service"; +import { RoleController } from "./role.controller"; +import { RoleService } from "./role.service"; +import { + ActivityMemberController, + OrganizationMemberController, +} from "./membership.controller"; +import { MembershipService } from "./membership.service"; + +@Module({ + imports: [AuthModule], + controllers: [ + PermissionController, + RoleController, + OrganizationMemberController, + ActivityMemberController, + ], + providers: [PermissionService, RoleService, MembershipService], + exports: [MembershipService], +}) +export class RbacModule {} diff --git a/src/rbac/role.controller.ts b/src/rbac/role.controller.ts new file mode 100644 index 0000000..546b6fc --- /dev/null +++ b/src/rbac/role.controller.ts @@ -0,0 +1,64 @@ +import { + Body, + Controller, + Delete, + ForbiddenException, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, +} from "@nestjs/common"; +import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger"; +import { Permissions } from "../auth/decorators/permissions.decorator"; +import { Public } from "../auth/decorators/public.decorator"; +import { RoleService } from "./role.service"; +import { CreateRoleDto, UpdateRoleDto } from "./dto/role.dto"; +import { RoleScope } from "../../generated/prisma/enums"; + +@ApiTags("Roles") +@Controller("roles") +export class RoleController { + constructor(private readonly roleService: RoleService) {} + + // Dev-only convenience for the mock UI: list all roles (id/name/scope/perms) + // without needing platform `role:manage`. Disabled in production. + @Public() + @Get("catalog") + @ApiOperation({ summary: "List all roles (dev only — for the test UI)" }) + catalog() { + if (process.env.NODE_ENV === "production") { + throw new ForbiddenException("Not available in production"); + } + return this.roleService.list(); + } + + @Permissions("role:manage") + @ApiQuery({ name: "scope", enum: RoleScope, required: false }) + @Get() + list(@Query("scope") scope?: RoleScope) { + return this.roleService.list(scope); + } + + @Permissions("role:manage") + @Post() + create(@Body() dto: CreateRoleDto) { + return this.roleService.create(dto); + } + + @Permissions("role:manage") + @Patch(":id") + update( + @Param("id", new ParseUUIDPipe()) id: string, + @Body() dto: UpdateRoleDto, + ) { + return this.roleService.update(id, dto); + } + + @Permissions("role:manage") + @Delete(":id") + remove(@Param("id", new ParseUUIDPipe()) id: string) { + return this.roleService.delete(id); + } +} diff --git a/src/rbac/role.service.spec.ts b/src/rbac/role.service.spec.ts new file mode 100644 index 0000000..d776dd1 --- /dev/null +++ b/src/rbac/role.service.spec.ts @@ -0,0 +1,91 @@ +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({})), + generateState: jest.fn(), +})); + +import { Test, TestingModule } from "@nestjs/testing"; +import { ConflictException, NotFoundException } from "@nestjs/common"; +import { RoleService } from "./role.service"; +import { PrismaService } from "../prisma/prisma.service"; +import { AuthService } from "../auth/auth.service"; + +describe("RoleService", () => { + let service: RoleService; + let prisma: any; + let authService: jest.Mocked; + + beforeEach(async () => { + prisma = { + role: { + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + permission: { count: jest.fn() }, + rolePermission: { deleteMany: jest.fn(), createMany: jest.fn() }, + membership: { findMany: jest.fn() }, + $transaction: jest.fn((fn: any) => fn(prisma)), + }; + const authMock = { + bumpTokenVersion: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + RoleService, + { provide: PrismaService, useValue: prisma }, + { provide: AuthService, useValue: authMock }, + ], + }).compile(); + + service = module.get(RoleService); + authService = module.get(AuthService); + }); + + it("rejects duplicate role name", async () => { + prisma.role.findUnique.mockResolvedValue({ id: "r-1" }); + await expect( + service.create({ name: "admin", permissionIds: [] }), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("creates role with given permissions", async () => { + prisma.role.findUnique.mockResolvedValue(null); + prisma.permission.count.mockResolvedValue(1); + prisma.role.create.mockResolvedValue({ id: "r-1" }); + + await service.create({ name: "editor", permissionIds: ["p-1"] }); + expect(prisma.role.create).toHaveBeenCalled(); + }); + + it("updating permissions bumps tokenVersion of all role members", async () => { + prisma.role.findUnique.mockResolvedValue({ id: "r-1" }); + prisma.permission.count.mockResolvedValue(1); + prisma.role.update.mockResolvedValue({ id: "r-1" }); + prisma.membership.findMany.mockResolvedValue([ + { memberId: "m-1" }, + { memberId: "m-2" }, + ]); + + await service.update("r-1", { permissionIds: ["p-1"] }); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-1"); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-2"); + }); + + it("delete throws when role not found", async () => { + prisma.role.findUnique.mockResolvedValue(null); + await expect(service.delete("missing")).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("delete bumps tokenVersion of affected members", async () => { + prisma.role.findUnique.mockResolvedValue({ id: "r-1" }); + prisma.membership.findMany.mockResolvedValue([{ memberId: "m-1" }]); + + await service.delete("r-1"); + expect(authService.bumpTokenVersion).toHaveBeenCalledWith("m-1"); + expect(prisma.role.delete).toHaveBeenCalledWith({ where: { id: "r-1" } }); + }); +}); diff --git a/src/rbac/role.service.ts b/src/rbac/role.service.ts new file mode 100644 index 0000000..a59f94b --- /dev/null +++ b/src/rbac/role.service.ts @@ -0,0 +1,117 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from "@nestjs/common"; +import { PrismaService } from "../prisma/prisma.service"; +import { withId } from "src/common/utils/db.util"; +import { AuthService } from "../auth/auth.service"; +import { CreateRoleDto, UpdateRoleDto } from "./dto/role.dto"; +import { RoleScope } from "../../generated/prisma/enums"; + +@Injectable() +export class RoleService { + constructor( + private readonly prisma: PrismaService, + private readonly authService: AuthService, + ) {} + + list(scope?: RoleScope) { + return this.prisma.role.findMany({ + where: scope ? { scope } : undefined, + orderBy: [{ scope: "asc" }, { name: "asc" }], + include: { permissions: { include: { permission: true } } }, + }); + } + + async create(dto: CreateRoleDto) { + const scope = dto.scope ?? RoleScope.PLATFORM; + const existing = await this.prisma.role.findUnique({ + where: { name_scope: { name: dto.name, scope } }, + }); + if (existing) { + throw new ConflictException( + `Role '${dto.name}' already exists in scope '${scope}'`, + ); + } + + await this.assertPermissionsExist(dto.permissionIds); + + return this.prisma.role.create({ + data: withId({ + name: dto.name, + scope, + description: dto.description, + permissions: { + create: dto.permissionIds.map((permission_id) => ({ permission_id })), + }, + }), + include: { permissions: { include: { permission: true } } }, + }); + } + + async update(id: string, dto: UpdateRoleDto) { + const role = await this.prisma.role.findUnique({ where: { id } }); + if (!role) throw new NotFoundException("Role not found"); + + const permissionsChanged = dto.permissionIds !== undefined; + if (dto.permissionIds) { + await this.assertPermissionsExist(dto.permissionIds); + } + + const updated = await this.prisma.$transaction(async (tx) => { + if (dto.permissionIds) { + await tx.rolePermission.deleteMany({ where: { role_id: id } }); + await tx.rolePermission.createMany({ + data: dto.permissionIds.map((permission_id) => ({ + role_id: id, + permission_id, + })), + }); + } + return tx.role.update({ + where: { id }, + data: { + name: dto.name, + description: dto.description, + }, + include: { permissions: { include: { permission: true } } }, + }); + }); + + if (permissionsChanged) { + await this.bumpMembersOfRole(id); + } + return updated; + } + + async delete(id: string) { + const role = await this.prisma.role.findUnique({ where: { id } }); + if (!role) throw new NotFoundException("Role not found"); + + await this.bumpMembersOfRole(id); + await this.prisma.role.delete({ where: { id } }); + return { ok: true }; + } + + private async assertPermissionsExist(ids: string[]) { + if (ids.length === 0) return; + const count = await this.prisma.permission.count({ + where: { id: { in: ids } }, + }); + if (count !== ids.length) { + throw new NotFoundException("One or more permissionIds are invalid"); + } + } + + private async bumpMembersOfRole(roleId: string) { + const memberships = await this.prisma.membership.findMany({ + where: { roleId }, + select: { memberId: true }, + distinct: ["memberId"], + }); + await Promise.all( + memberships.map((m) => this.authService.bumpTokenVersion(m.memberId)), + ); + } +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..e08fadf 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -1,10 +1,11 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import request from 'supertest'; -import { App } from 'supertest/types'; -import { AppModule } from './../src/app.module'; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication } from "@nestjs/common"; +import { ExpressAdapter } from "@nestjs/platform-express"; +import request from "supertest"; +import { App } from "supertest/types"; +import { AppModule } from "./../src/app.module"; -describe('AppController (e2e)', () => { +describe("AppController (e2e)", () => { let app: INestApplication; beforeEach(async () => { @@ -12,14 +13,14 @@ describe('AppController (e2e)', () => { imports: [AppModule], }).compile(); - app = moduleFixture.createNestApplication(); + app = moduleFixture.createNestApplication(new ExpressAdapter()); await app.init(); }); - it('/ (GET)', () => { + it("/ (GET)", () => { return request(app.getHttpServer()) - .get('/') + .get("/") .expect(200) - .expect('Hello World!'); + .expect("Hello World!"); }); }); diff --git a/test/auth.e2e-spec.ts b/test/auth.e2e-spec.ts new file mode 100644 index 0000000..9501c35 --- /dev/null +++ b/test/auth.e2e-spec.ts @@ -0,0 +1,394 @@ +/** + * Auth e2e — covers the rotating-refresh + RBAC flows. + * + * Requirements: + * - DATABASE_URL points at a test DB you don't mind wiping. + * - NODE_ENV !== "production" (we force "development" here). + * + * Run with: pnpm test:e2e + */ +import "dotenv/config"; +import { Test, TestingModule } from "@nestjs/testing"; +import { INestApplication, ValidationPipe } from "@nestjs/common"; +import { Reflector } from "@nestjs/core"; +import { ExpressAdapter } from "@nestjs/platform-express"; +import request from "supertest"; +import cookieParser from "cookie-parser"; +import { AppModule } from "../src/app.module"; +import { JwtAuthGuard } from "../src/auth/guards/jwt-auth.guard"; +import { PermissionGuard } from "../src/auth/guards/permission.guard"; +import { PermissionsCacheService } from "../src/auth/services/permissions-cache.service"; +import { PrismaService } from "../src/prisma/prisma.service"; +import { withId } from "../src/common/utils/db.util"; + +const API = "/api/v1"; + +function getCookie( + setCookie: string[] | undefined, + name: string, +): string | null { + if (!setCookie) return null; + for (const c of setCookie) { + const [pair] = c.split(";"); + const [k, v] = pair.split("="); + if (k === name) return v; + } + return null; +} + +describe("Auth e2e", () => { + let app: INestApplication; + let prisma: PrismaService; + let baseUrl: string; + + beforeAll(async () => { + process.env.NODE_ENV = "development"; + + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(new ExpressAdapter()); + app.use(cookieParser()); + app.setGlobalPrefix("api/v1"); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + const reflector = app.get(Reflector); + const prismaForGuard = app.get(PrismaService); + const cacheForGuard = app.get(PermissionsCacheService); + app.useGlobalGuards( + new JwtAuthGuard(reflector), + new PermissionGuard(reflector, prismaForGuard, cacheForGuard), + ); + await app.init(); + await app.listen(0); + const server = app.getHttpServer(); + const addr = server.address(); + const port = typeof addr === "object" && addr ? addr.port : 0; + baseUrl = `http://127.0.0.1:${port}`; + + prisma = app.get(PrismaService); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Order respects FK constraints. + await prisma.refreshToken.deleteMany({}); + await prisma.invitation.deleteMany({}); + await prisma.membership.deleteMany({}); + await prisma.rolePermission.deleteMany({}); + await prisma.activityContent.deleteMany({}); + await prisma.activity.deleteMany({}); + await prisma.organization.deleteMany({}); + await prisma.role.deleteMany({}); + await prisma.permission.deleteMany({}); + await prisma.memberLink.deleteMany({}); + await prisma.memberProvider.deleteMany({}); + await prisma.member.deleteMany({}); + }); + + describe("auth lifecycle", () => { + it("dev-login → /me → refresh → logout", async () => { + const agent = request.agent(baseUrl); + + const login = await agent + .post(`${API}/auth/dev-login`) + .send({ email: "alice@example.com" }); + expect(login.status).toBe(201); + expect(login.body.user.email).toBe("alice@example.com"); + + const setCookies = login.headers["set-cookie"] as unknown as + | string[] + | undefined; + expect(getCookie(setCookies, "access_token")).toBeTruthy(); + expect(getCookie(setCookies, "refresh_token")).toBeTruthy(); + + const me1 = await agent.get(`${API}/auth/me`); + expect(me1.status).toBe(200); + expect(me1.body.email).toBe("alice@example.com"); + + const oldRefresh = getCookie(setCookies, "refresh_token"); + + const refresh = await agent.post(`${API}/auth/refresh`); + expect(refresh.status).toBe(201); + const refreshCookies = refresh.headers["set-cookie"] as unknown as + | string[] + | undefined; + const newAccess = getCookie(refreshCookies, "access_token"); + const newRefresh = getCookie(refreshCookies, "refresh_token"); + expect(newAccess).toBeTruthy(); + expect(newRefresh).toBeTruthy(); + // Access JWT may equal the original if signed within the same second + // (same sub+v+iat). Refresh token is random — it must rotate. + expect(newRefresh).not.toBe(oldRefresh); + + const me2 = await agent.get(`${API}/auth/me`); + expect(me2.status).toBe(200); + + const logout = await agent.post(`${API}/auth/logout`); + expect(logout.status).toBe(201); + + const me3 = await agent.get(`${API}/auth/me`); + expect(me3.status).toBe(401); + }); + }); + + describe("refresh-token reuse detection", () => { + it("replaying a rotated refresh token kills all sessions", async () => { + const agent = request.agent(baseUrl); + + const login = await agent + .post(`${API}/auth/dev-login`) + .send({ email: "bob@example.com" }); + const loginCookies = login.headers["set-cookie"] as unknown as string[]; + const stolenRefresh = getCookie(loginCookies, "refresh_token")!; + expect(stolenRefresh).toBeTruthy(); + + // Legitimate rotation — agent's cookie jar advances. + const ok = await agent.post(`${API}/auth/refresh`); + expect(ok.status).toBe(201); + + // /me still works on the rotated session. + const meWarm = await agent.get(`${API}/auth/me`); + expect(meWarm.status).toBe(200); + + // Attacker replays the OLD (now-revoked) refresh token from a clean client. + const replay = await request(baseUrl) + .post(`${API}/auth/refresh`) + .set("Cookie", `refresh_token=${stolenRefresh}`); + expect(replay.status).toBe(401); + + // Reuse detection bumped tokenVersion → the legit agent's access is now invalid too. + const meCold = await agent.get(`${API}/auth/me`); + expect(meCold.status).toBe(401); + }); + }); + + describe("scoped RBAC", () => { + // Builds the org/event permission catalogue + roles used by the scoped tests. + async function seedScopedFixtures() { + const codes = ["org:member:manage", "event:member:manage", "event:edit"]; + const perm: Record = {}; + for (const code of codes) { + const p = await prisma.permission.create({ data: withId({ code }) }); + perm[code] = p.id; + } + + // Org role that also carries event:member:manage so it cascades to events. + const orgManager = await prisma.role.create({ + data: withId({ + name: "OrgManager", + scope: "ORG", + permissions: { + create: [ + { permission_id: perm["org:member:manage"] }, + { permission_id: perm["event:member:manage"] }, + ], + }, + }), + }); + const eventEditor = await prisma.role.create({ + data: withId({ + name: "EventEditor", + scope: "EVENT", + permissions: { create: [{ permission_id: perm["event:edit"] }] }, + }), + }); + + const org = await prisma.organization.create({ + data: withId({ name: "JSDC", slug: "jsdc" }), + }); + const activity = await prisma.activity.create({ + data: withId({ + name: "JSDC 2026", + slug: "jsdc-2026", + organizationId: org.id, + startAt: new Date(), + endAt: new Date(), + supportedLanguages: ["en"], + }), + }); + + return { org, activity, orgManager, eventEditor, perm }; + } + + async function login(email: string) { + const agent = request.agent(baseUrl); + const res = await agent.post(`${API}/auth/dev-login`).send({ email }); + return { agent, body: res.body }; + } + + it("org role cascades to events: org manager assigns an event role", async () => { + const { org, activity, orgManager, eventEditor } = + await seedScopedFixtures(); + + const admin = await login("orgadmin@example.com"); + await prisma.membership.create({ + data: withId({ + memberId: admin.body.user.id, + roleId: orgManager.id, + scopeType: "ORG", + organizationId: org.id, + }), + }); + // Re-login to refresh the cached scoped permissions. + const adminReLogin = await admin.agent + .post(`${API}/auth/dev-login`) + .send({ email: "orgadmin@example.com" }); + expect(adminReLogin.body.user.permissions.org[org.id]).toEqual( + expect.arrayContaining(["org:member:manage", "event:member:manage"]), + ); + + const target = await login("speaker@example.com"); + const targetId = target.body.user.id; + expect(target.body.user.permissions.event).toEqual({}); + + // Org manager manages an EVENT member via the org→event cascade. + const assign = await admin.agent + .put(`${API}/activities/${activity.id}/members/${targetId}/roles`) + .send({ roleIds: [eventEditor.id] }); + expect(assign.status).toBe(200); + + // Target's old cookie is revoked; re-login shows the event permission. + expect((await target.agent.get(`${API}/auth/me`)).status).toBe(401); + const targetReLogin = await target.agent + .post(`${API}/auth/dev-login`) + .send({ email: "speaker@example.com" }); + expect(targetReLogin.body.user.permissions.event[activity.id]).toEqual([ + "event:edit", + ]); + }); + + it("an event-only member cannot manage a different activity", async () => { + const { activity, eventEditor, perm } = await seedScopedFixtures(); + + // A second activity with no shared org. + const otherActivity = await prisma.activity.create({ + data: withId({ + name: "Other", + slug: "other", + startAt: new Date(), + endAt: new Date(), + supportedLanguages: ["en"], + }), + }); + + // An EVENT role that can manage activity members. + const eventManagerRole = await prisma.role.create({ + data: withId({ + name: "EventManager", + scope: "EVENT", + permissions: { + create: [{ permission_id: perm["event:member:manage"] }], + }, + }), + }); + + const user = await login("eventonly@example.com"); + await prisma.membership.create({ + data: withId({ + memberId: user.body.user.id, + roleId: eventManagerRole.id, + scopeType: "EVENT", + activityId: activity.id, + }), + }); + await user.agent + .post(`${API}/auth/dev-login`) + .send({ email: "eventonly@example.com" }); + + // Can manage its own activity… + const ownTarget = await login("t1@example.com"); + const ok = await user.agent + .put( + `${API}/activities/${activity.id}/members/${ownTarget.body.user.id}/roles`, + ) + .send({ roleIds: [eventEditor.id] }); + expect(ok.status).toBe(200); + + // …but not a different activity. + const denied = await user.agent + .put( + `${API}/activities/${otherActivity.id}/members/${ownTarget.body.user.id}/roles`, + ) + .send({ roleIds: [eventEditor.id] }); + expect(denied.status).toBe(403); + }); + + it("invitation accept grants the scoped role; expired invite returns 410", async () => { + const { org, orgManager } = await seedScopedFixtures(); + + const admin = await login("owner@example.com"); + await prisma.membership.create({ + data: withId({ + memberId: admin.body.user.id, + roleId: orgManager.id, + scopeType: "ORG", + organizationId: org.id, + }), + }); + await admin.agent + .post(`${API}/auth/dev-login`) + .send({ email: "owner@example.com" }); + + // Invite a teammate to the org. + const invite = await admin.agent + .post(`${API}/organizations/${org.id}/invitations`) + .send({ email: "invitee@example.com", roleId: orgManager.id }); + expect(invite.status).toBe(201); + const token = invite.body.token; + expect(token).toBeTruthy(); + + // Invitee logs in and accepts. + const invitee = await login("invitee@example.com"); + const accept = await invitee.agent + .post(`${API}/invitations/accept`) + .send({ token }); + expect(accept.status).toBe(201); + + const inviteeReLogin = await invitee.agent + .post(`${API}/auth/dev-login`) + .send({ email: "invitee@example.com" }); + expect(inviteeReLogin.body.user.permissions.org[org.id]).toEqual( + expect.arrayContaining(["org:member:manage"]), + ); + + // An expired invitation is rejected with 410 Gone. + const expired = await prisma.invitation.create({ + data: withId({ + email: "late@example.com", + token: "expired-token-123", + status: "PENDING", + scopeType: "ORG", + organizationId: org.id, + roleId: orgManager.id, + invitedById: admin.body.user.id, + expiresAt: new Date(Date.now() - 1000), + }), + }); + const late = await login("late@example.com"); + const acceptLate = await late.agent + .post(`${API}/invitations/accept`) + .send({ token: expired.token }); + expect(acceptLate.status).toBe(410); + }); + + it("a member with no roles cannot call protected RBAC endpoints", async () => { + const { org } = await seedScopedFixtures(); + const nobody = await login("nobody@example.com"); + + const list = await nobody.agent.get( + `${API}/organizations/${org.id}/invitations`, + ); + expect(list.status).toBe(403); + }); + }); +}); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index e9d912f..461cf26 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -5,5 +5,12 @@ "testRegex": ".e2e-spec.ts$", "transform": { "^.+\\.(t|j)s$": "ts-jest" + }, + "transformIgnorePatterns": ["node_modules/(?!(.*/)?(passport-jwt)/)"], + "setupFiles": ["/jest-e2e.setup.ts"], + "moduleNameMapper": { + "^src/(.*)$": "/../src/$1", + "^(\\./.+)\\.js$": "$1", + "^(\\.\\./.+)\\.js$": "$1" } } diff --git a/test/jest-e2e.setup.ts b/test/jest-e2e.setup.ts new file mode 100644 index 0000000..610bb1c --- /dev/null +++ b/test/jest-e2e.setup.ts @@ -0,0 +1,81 @@ +/** + * E2E global setup — runs (via `setupFiles`) BEFORE any test module is imported. + * + * The e2e suite WIPES the database it connects to (see `beforeEach` in the + * specs). It must therefore NEVER be allowed to point at a real / remote DB. + * This file is the single choke point that guarantees that: + * + * 1. E2E always uses TEST_DATABASE_URL, never the app's DATABASE_URL. + * 2. If TEST_DATABASE_URL is unset, we abort — no silent fallback. + * 3. If TEST_DATABASE_URL points anywhere other than localhost, we abort. + * + * Because this runs before the specs' `import "dotenv/config"`, and dotenv does + * not override already-set vars, forcing DATABASE_URL here makes it stick for + * the whole run (PrismaService reads process.env.DATABASE_URL at construction). + */ +import { config as loadEnv } from "dotenv"; + +// Load .env so TEST_DATABASE_URL is available. `override` defaults to false, so +// any value already exported in the shell wins — and we set DATABASE_URL +// ourselves below regardless. +loadEnv(); + +const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); +// Explicit deny-list of known remote hosts, checked before the generic rule as +// a belt-and-suspenders guard against typos / partial matches. +const REMOTE_MARKERS = [ + "hnd1.clusters.zeabur.com", + "clusters.zeabur.com", + "zeabur.com", +]; + +function abort(message: string): never { + // Throwing from setupFiles fails the entire suite before any DB call. + throw new Error( + `\n\n❌ E2E aborted — refusing to run against an unsafe database.\n ${message}\n` + + ` Set TEST_DATABASE_URL to a local, disposable Postgres, e.g.\n` + + ` TEST_DATABASE_URL="postgresql://user@localhost:5432/form_platform_test"\n`, + ); +} + +const testUrl = process.env.TEST_DATABASE_URL; + +if (!testUrl || testUrl.trim() === "") { + abort( + "TEST_DATABASE_URL is not set. E2E will NOT fall back to DATABASE_URL.", + ); +} + +let host: string; +try { + // Postgres URLs parse fine with the WHATWG URL parser. + host = new URL(testUrl).hostname.toLowerCase(); +} catch { + abort(`TEST_DATABASE_URL is not a valid URL: "${testUrl}"`); +} + +for (const marker of REMOTE_MARKERS) { + if (testUrl.toLowerCase().includes(marker)) { + abort(`TEST_DATABASE_URL contains a known remote host ("${marker}").`); + } +} + +if (!LOCAL_HOSTS.has(host)) { + abort( + `TEST_DATABASE_URL host "${host}" is not local. Only localhost/127.0.0.1 are allowed.`, + ); +} + +// Passed every check — force the app (PrismaService, prisma.config.ts) onto the +// test DB. This overrides any DATABASE_URL loaded from .env. +process.env.DATABASE_URL = testUrl; + +// E2E tests don't exercise the GitHub OAuth flow — mock arctic to dodge its +// transitive ESM-only deps (@oslojs/*) which Jest can't parse. +jest.mock("arctic", () => ({ + GitHub: jest.fn().mockImplementation(() => ({ + createAuthorizationURL: jest.fn(), + validateAuthorizationCode: jest.fn(), + })), + generateState: jest.fn(() => "test-state"), +})); diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..90031bb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "web", "**/*spec.ts"] } diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..54d26d1 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,8 @@ +# Frontend dev server port. Matches the backend's CLIENT_URL / CORS origin. +VITE_PORT=3000 + +# Backend API base URL. +# The backend runs on PORT=4000 with the `api/v1` global prefix (see ../.env). +# In dev, vite.config.ts also proxies "/api" -> http://localhost:4000, so you +# can alternatively set this to "/api/v1" to go through the same origin. +VITE_API_BASE_URL=http://localhost:4000/api/v1 diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..7a71ab2 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,28 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Env files (keep .env.example committed) +.env +.env.*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/components.json b/web/components.json new file mode 100644 index 0000000..5c23ec4 --- /dev/null +++ b/web/components.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "radix-nova", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "iconLibrary": "lucide", + "rtl": false, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "menuColor": "default", + "menuAccent": "subtle", + "registries": {} +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..4e4dca1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + + Form Platform + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..4669b42 --- /dev/null +++ b/web/package.json @@ -0,0 +1,41 @@ +{ + "name": "form-platform-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint ." + }, + "dependencies": { + "@fontsource-variable/geist": "^5.3.0", + "@hookform/resolvers": "^5.4.0", + "axios": "^1.18.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.26.0", + "next-themes": "^0.4.6", + "radix-ui": "^1.6.5", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-hook-form": "^7.82.0", + "react-router-dom": "^7.18.1", + "shadcn": "^4.14.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.6.0", + "tw-animate-css": "^1.4.0", + "zod": "^4.4.3" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.3", + "@types/node": "^26.1.1", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.4", + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "vite": "^8.1.5" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..f888386 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,5512 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource-variable/geist': + specifier: ^5.3.0 + version: 5.3.0 + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.4.0(react-hook-form@7.82.0(react@19.2.8)) + axios: + specifier: ^1.18.1 + version: 1.18.1 + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + lucide-react: + specifier: ^1.26.0 + version: 1.26.0(react@19.2.8) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + radix-ui: + specifier: ^1.6.5 + version: 1.6.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-hook-form: + specifier: ^7.82.0 + version: 7.82.0(react@19.2.8) + react-router-dom: + specifier: ^7.18.1 + version: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + shadcn: + specifier: ^4.14.1 + version: 4.14.1(typescript@7.0.2) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + tailwind-merge: + specifier: ^3.6.0 + version: 3.6.0 + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@tailwindcss/vite': + specifier: ^4.3.3 + version: 4.3.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)) + '@types/node': + specifier: ^26.1.1 + version: 26.1.1 + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.4 + version: 6.0.4(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0)) + tailwindcss: + specifier: ^4.3.3 + version: 4.3.3 + typescript: + specifier: ^7.0.2 + version: 7.0.2 + vite: + specifier: ^8.1.5 + version: 8.1.5(@types/node@26.1.1)(jiti@2.7.0) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-annotate-as-pure@7.29.7': + resolution: {integrity: sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-create-class-features-plugin@7.29.7': + resolution: {integrity: sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-member-expression-to-functions@7.29.7': + resolution: {integrity: sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-optimise-call-expression@7.29.7': + resolution: {integrity: sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==} + engines: {node: '>=6.9.0'} + + '@babel/helper-plugin-utils@7.29.7': + resolution: {integrity: sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-replace-supers@7.29.7': + resolution: {integrity: sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + resolution: {integrity: sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-syntax-jsx@7.29.7': + resolution: {integrity: sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-syntax-typescript@7.29.7': + resolution: {integrity: sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-modules-commonjs@7.29.7': + resolution: {integrity: sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-typescript@7.29.7': + resolution: {integrity: sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/preset-typescript@7.29.7': + resolution: {integrity: sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@dotenvx/dotenvx@1.75.1': + resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} + hasBin: true + + '@dotenvx/primitives@0.8.0': + resolution: {integrity: sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg==} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@fontsource-variable/geist@5.3.0': + resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@hookform/resolvers@5.4.0': + resolution: {integrity: sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==} + peerDependencies: + react-hook-form: ^7.55.0 + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@napi-rs/wasm-runtime@1.1.6': + resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/types@0.139.0': + resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} + + '@radix-ui/number@1.1.3': + resolution: {integrity: sha512-Road2bidD0uu/1BGDOWNdPI06g0lIRy6IF9GZcIrDK2KGItfor8IQwQa+yM2ERgHM1MmHxaxpTzk0/Jp42lNfA==} + + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-accessible-icon@1.1.13': + resolution: {integrity: sha512-2/0RrtTngKZVkOFYDlvhe6JIsUravRnYGwH1iJ+qt+wPu8/fPu3HTLRkQptyIkpLovAy8qvHHJs9cvYJ3Uyqqg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-accordion@1.2.18': + resolution: {integrity: sha512-CGuUFvhjScXBF/bhjaU9xlgai2ikYqJRazXhMzngoUaZI7QaxAw24Ae3S/pR0b69p8GqW6i+CKJmx9JPNnZ4QQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-alert-dialog@1.1.21': + resolution: {integrity: sha512-VbSR148lNkE8JdWnOsYX72YdivmCAtcrot1dl8UmT0ZZRKNR3hHJUpQxJOw7jWlY/JFemn4oaDXxCxpD594B7Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-arrow@1.1.13': + resolution: {integrity: sha512-0Q310knIY0K+mkmncU9FxLggfW7V49Ok2oY+iu27KkERTsQXxYCIC8XW5QoK4w7Jp1z00vT5xj3oxTcn7QlcTg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-aspect-ratio@1.1.13': + resolution: {integrity: sha512-dD/EvbOiLskcdBKgNAeUmbTBCJfdCGp85fpFe3lmziE4ASbXuFERQCAB3sR41yvM+P711kJsTBNL9piP60urbA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-avatar@1.2.4': + resolution: {integrity: sha512-u4Sz6ajq2WobGMqbsNXnFI/3JP/o7TUBkEmQ4BRUYofPDQawdO3WWYy5907c9VgOFX+ETjKI3VxnnrKYAF7Pmg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-checkbox@1.3.9': + resolution: {integrity: sha512-YPXZlqjrKVqKtVD2F828CApHwu+PEu30i7x0Y9gg6SbQ3fJbcaQlDkGPT0gW6HjxoCjAEt8uJ494F1S61KpIjQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collapsible@1.1.18': + resolution: {integrity: sha512-aNMp8humfYzWERz5gxGb/eLNkB0ZthypLbNlNolzdgzFTeN78e/rnn9rxCdOaSsg/zClTOJ/OkjXYx9PUEPATw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.13': + resolution: {integrity: sha512-Q8xYqNRFObXPmi45bFSkGdM2JA5hjBeYGFqzg6lZR3wp6b+cciraVq8DdneTabtws+uOYqjmsvmKOufk/M9Cyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.4': + resolution: {integrity: sha512-pWJo6lQAfR6uy1n7ii7PaCc9dLPwTXDYbQpORZU5B548Aqvl2pP1SM1vJGKyxIFqZMHRopRO4CQYX2iXAIB5jA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.5': + resolution: {integrity: sha512-wbvSjqc7r0xKmfhrMCiCPxSTv/aSHU0cCAy0tELthJbaIzT7dkAcevKuoGKbDLiPG2VePgMVqLi2sWb6tDnzvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.2.1': + resolution: {integrity: sha512-EraVbFjiIjibpLr6EjvEDmSCYJU2SlKDMiO+qEK/D9GOWnQoAQlpQo2occGYC1UM9MBeEx5Bek3UtW/Qi57vAg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dialog@1.1.21': + resolution: {integrity: sha512-h+7qMDDmZJ8qTSPrwNyKb/PACY0ehtN8QOBlCz+C2C1jgehKekdhmHddG9YQk8BF/sHJqglPjte+jA1Jrp9HcA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-direction@1.1.3': + resolution: {integrity: sha512-OdgAA/xb6WOQMKNn0mtbYoruMG6YMaBOnq+evWxSpMmiEMBdeFZawnIWRfPPeVXCRm+0lnN8jpJLjhD7/UIxWw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.17': + resolution: {integrity: sha512-QAXwa38pG0xNAYh1pjdSaf86NrkqsMoDNmget/Y7X8O8E/C3Iqlj9GAPE4DfX9BPLXc7WH2TWSzMRnIoCdcjzQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.22': + resolution: {integrity: sha512-wZRLTjZaJf1HTxx0YepFKZUuo+SZMi+Cr4kJ2fuA5FxDSUE0vHDTscahUaU+jfz6LFk4OuguwqJs5K0JsIsIBA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.5': + resolution: {integrity: sha512-UQvlB7L/BYh3P8MLvwZnQkH521EDos40Rwnbt5+Qpg4Vbk0z3xJjRUmR6+aka4aT1IQQXFdO5bNPoE7cvFl5xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.14': + resolution: {integrity: sha512-/x4htnJfmW53MplkrePaDpf1o/rN1C++g88WpVobULXbSyC19NtLkXmewuJ/HCaceSmfKDNL5gOXcBGnuAvnvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-form@0.1.14': + resolution: {integrity: sha512-ZCuPb54PQ3ZQEdQ6G1PrMBfXXzAyaVoCI9Fl+bWOwYZKTOcFjPHL7JQ3tCb6CX6Ut81GZLWKgIpV6OS3n5z5Ig==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-hover-card@1.1.21': + resolution: {integrity: sha512-ByFLyi+kdTGkObxIt5wKB3Ru3rtJRuYUqPEtqiv1HNYqutqbn1wDuNmVzI6iwus9qfY3vwpNgvx/UVZUGTWC1A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.3': + resolution: {integrity: sha512-f/Wxm0ctyMymUJK0fqTSQlm85rbzdAkoNbPXJQ5+6caowVO8Yx+NWGjGz/oGhs/D+WIbbQpOrU0hU2Li2/42xQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-label@2.1.13': + resolution: {integrity: sha512-kj7BXyM4uG0dyeoEdVmPQAOFN291+iJOtiGTvkXe1ijJWLQbAHpFNkopvqquaMVn+zjqTZRhnefESAinMErHKg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menu@2.1.22': + resolution: {integrity: sha512-xCFrVLttGDPwMjIPI6GS+tm3IGUNKsPXffrJQIPpQJuE9Qy1bU9GNUCkwranU/pEqyBlwMxcBIK/FUJFAZ9SqQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-menubar@1.1.22': + resolution: {integrity: sha512-jDkcZXd5rN4b/S9nnEv9KZajaQ7+RO98OCWqAxrULTJtkSvODlNOO+EIxNG/d4QIF6QOqK5ZZrTF9MYl6eQdiQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-navigation-menu@1.2.20': + resolution: {integrity: sha512-in/exCQPatmmBmQZrtQkK4UrysEp8VGEuw5o8Bx3ozxbfg7DBb4H5j3DgxGz4N59J02NRVzhsWFtujZgy5ue3w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-one-time-password-field@0.1.14': + resolution: {integrity: sha512-F3vMSK+d+huI3a4Zm6j2hUsS+/gByVbsechJwp/ZCZBCrusXHvBeZZz9aMqXqqYNYu23mtvVMt6AeHDds8EU7w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-password-toggle-field@0.1.9': + resolution: {integrity: sha512-JFkmnwZFD357odQkpzfxz++bpohQpaGCT/4PK6oRd4R/Rxzg02uFDucFzZDOTIOz6vWSluIlz91zelleg9BheA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popover@1.1.21': + resolution: {integrity: sha512-+AJ7P5oRcFqFtv7MaCD8fOuK3l5qQBAMdWEOnUOYd3JfHOeIUe7TnHHSSP15CtWwH4KrotyDCTzodOIoONh8vw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.5': + resolution: {integrity: sha512-6hLng2Rs45IZcvZ31BNvMeaFEMMEbtsog4xPcb8LZrGfyoV9fdAXqB9UKhLpTHtL0+E2DhSr6VW54Tehd6ksAQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.15': + resolution: {integrity: sha512-kAfBVJUKNNKZuyGQXXG6rKolAV2KAmxxVkPXJgoq9dEFTl39286RufHQFNTL8rzha4vP8159BJ6hMGpB+bqv7A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.9': + resolution: {integrity: sha512-LTi1v05bprIb8/GSY/GWusI0jfsYjQ3CD3Nin8o7jVxnpHzVQfzjOQJoJTQkE9bdmOnsS7SFdhkXiBv8PrYnxw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.8': + resolution: {integrity: sha512-DOlK1BdcIeYYUcFkSYFka4v1h95XTov93b0jCgW1EEiZuIhdwHY2NlE1teLIh+p0uBsuZI5A+voay+iVWpprfA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-progress@1.1.14': + resolution: {integrity: sha512-lq5VXWp/t/b8MaqbRMAItXICc/jwhzJt0ZZ+au/CBtTYkX9EfAsSyGQL5brUVBgf9tUTKCoJEZlDl+uJF/08xQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-radio-group@1.4.5': + resolution: {integrity: sha512-BM1xQgw1gpgPSw05U4hJCMDX9GmAS6b6vKZANuNQZo0vf/ah7NZDAnjLd4tPg3z2vMvlI0CV+B2CFM9Z19oQDA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.17': + resolution: {integrity: sha512-YdJmjETw7Py+4Nziv71jgZ6fH7xQgB5p+pQ5rV8Iufyrm0RHSijec/UV1zPfQoK/YcfzB9nOl/2bi1zf1SjRMQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-scroll-area@1.2.16': + resolution: {integrity: sha512-kEcmCenIJ+W5xlCv1u9VKDdPmzzGyfrJwEneY+02GH+lJRXrGpkiTkyjrfkspuRw6DXt4uduwT5c4xD8HTv27Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-select@2.3.5': + resolution: {integrity: sha512-6CQx0xtdarAkSCsqgLJaz+coYQaru7rt8K6gMItR2rA+WP8/Ig6kRAzP6Zw88RccG98IZnszqJlJx9hejYOL3A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-separator@1.1.13': + resolution: {integrity: sha512-wbagLhy4grekqiT6hU0WNP09FpmemTXbNWg4QEawaw9dix7hRIcrmDNnLDasOsKVLtArEznhgZKe5Gm4U6oPXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slider@1.4.5': + resolution: {integrity: sha512-7E8C13SdQKQxYganILiVZGEwM0NSbfbFcnNJ93kP++wD2pMT1SMsG3c04PFUlbPpaSXgicYgPeBndgJTvV3NKg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.1': + resolution: {integrity: sha512-Bu/aAQHFFh6/QAvXAeUMurJ9fbW0JUIqlojU/yBXZ7cAVqy75Y7JYYyuCr9zLNF0p4WWoJYV54CTUIf4l7FzTw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-switch@1.3.5': + resolution: {integrity: sha512-e1YrvGvEjIqDhqKjQ4Q+IDkMfiY30u3FPz9CZXZMlQ8mU3kZ2PdeBM8Gssc48CDopPizNorKumGhjySBfO7BDQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tabs@1.1.19': + resolution: {integrity: sha512-4wbGf98pX2ZwYqHMVB/dakeMPyH4xSJ9MSDaCCWXlIpYt0VYY6mtZxKSTiGS/F3tk0yrEYmXE1V8uwG7TxDCcQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toast@1.2.21': + resolution: {integrity: sha512-/o8uHzH2z6ZM59Dt1UPFj8aYJ0DjPu0EuQXQ7uuYJTQLH5mNqxa1KuBfbpnklgXOOjyAtTp/tll0Nm+Nkz+bEg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle-group@1.1.17': + resolution: {integrity: sha512-KKsrxb1cZREc2RI5DOGwlUSpaPW40xIaK1/DXXqYDb7slRE8pkRa+iF2aFbYiuMJ+aNWpCSChiN5gFe3bVveaQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toggle@1.1.16': + resolution: {integrity: sha512-pp4TLY3N4osN9IB4TLEaaZRGooBmCi/XJD65gSCs5tvsKofM9WgiBToNtGrmR0k3sG4jFj4Nw4utsIvQraWA9g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-toolbar@1.1.17': + resolution: {integrity: sha512-MjorZ0XQiWfCrrpStNXw/hX0HnG7VEzCgcSV226Ox8/WzS5yq0SiMhsLFE3LlXhdghlakFRFdE6eegtmUQW7Mg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-tooltip@1.2.14': + resolution: {integrity: sha512-C/JxCKJJac+wtHPW1yFBSN8Ssuaufy2jYQMgyiJYyW4Fw0WJfDWXQDAly1qsbd1YDznH+sYitaljhf8igPCvmA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.3': + resolution: {integrity: sha512-AUS7HoBBAncIsGMLNG+CcpLuJ+JIBbZzmyM8Qdb1eIThX0AlhSSC6wn40xfBlPE+ypx/vSSiRWnklUAjy3U3UA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.5': + resolution: {integrity: sha512-UB1dXpxvHjR48poyKdKdTm7jT0kp3elkUKdKQiOkirlbYumqXinSJtrjDsr9maXNPvL12bKI4CDSmydms/9Aeg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.4': + resolution: {integrity: sha512-XYcfa6wlXDCwQtePuEiPmXLSAhGL4DWtedSyRgGbG3y10mw+OnrLp6SyeY1gJFMiYF0Dx0nMAX9InylKbLEFQQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-escape-keydown@1.1.4': + resolution: {integrity: sha512-4MSb5SPbMH02uUn19tLkO5Bav66ZQg/zFbedo7FWLWGFg9r8Z9xSog7QGnq9ZceurzBVIAIwgzANg6ExWc8HkQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.2': + resolution: {integrity: sha512-2+gAVu9uaSLbopCTvvuWX9MIgEOlyqXC1Ok+KLCO7cZPSHLENs7dL0KbFQUGFIcFKmDW/bmaQRJtd8E3cnMRUw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.3': + resolution: {integrity: sha512-rDiah9wvtqihWtWz02XreeRKIxt2EJF8y5D9rtY9l5A2zxePAtcPiOMpDugNRw5bFHz+1/8viVoc7ZVKiJknCw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-previous@1.1.3': + resolution: {integrity: sha512-kbefNKla5AGhgvMqrg/qS7mYVLXTEHQLqoFIS03nKN2dAfQQ5PK5Tp+2LxS7BV6a6l4HHv4jTg6rtV2Uajwe2w==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.3': + resolution: {integrity: sha512-W0GSYZFKEfi6raMiMEfJSngvVbFDIyxtW5JuVg5NoQBY59l0dVQVGLbhog/tOdwq0qtZ+TXuX1ikSNan4/IZXA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.3': + resolution: {integrity: sha512-jJq6tQQLvO/z4uWbztjwPV+1a/+H4rCaWypasLB/ac8DEdd6+p7dIm7o3F6P2KZPGkPMqD4s5424EdAdoU+GLA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.9': + resolution: {integrity: sha512-seuZXNZVCz1kLSQMRO/TdNOSahBI3S5nXE4jzO7u7aFRWQlMAnwyrr8vKMkEU115fCLEyzR2YyjnP+aRMxK34w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + + '@rolldown/binding-android-arm64@1.1.5': + resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.1.5': + resolution: {integrity: sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.1.5': + resolution: {integrity: sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.1.5': + resolution: {integrity: sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + resolution: {integrity: sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + resolution: {integrity: sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.1.5': + resolution: {integrity: sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + resolution: {integrity: sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + resolution: {integrity: sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.1.5': + resolution: {integrity: sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.1.5': + resolution: {integrity: sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.1.5': + resolution: {integrity: sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.1.5': + resolution: {integrity: sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + resolution: {integrity: sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.1.5': + resolution: {integrity: sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sec-ant/readable-stream@0.4.1': + resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + + '@standard-schema/utils@0.3.0': + resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} + + '@tailwindcss/node@4.3.3': + resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==} + + '@tailwindcss/oxide-android-arm64@4.3.3': + resolution: {integrity: sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + resolution: {integrity: sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.3': + resolution: {integrity: sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + resolution: {integrity: sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + resolution: {integrity: sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + resolution: {integrity: sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + resolution: {integrity: sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + resolution: {integrity: sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + resolution: {integrity: sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + resolution: {integrity: sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + resolution: {integrity: sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + resolution: {integrity: sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.3': + resolution: {integrity: sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.3': + resolution: {integrity: sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@ts-morph/common@0.27.0': + resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} + + '@tybys/wasm-util@0.10.3': + resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitejs/plugin-react@6.0.4': + resolution: {integrity: sha512-XcCQz0TBpBgljhj0gMuuDj49i6Ytqh5q1osT/Gp5uAVJUCTWxyskk/l1jwYYiu2xcNHHipdMz40EGfM1VdamVg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + ajv-formats@2.1.1: + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + + ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + atomically@1.7.0: + resolution: {integrity: sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w==} + engines: {node: '>=10.12.0'} + + axios@1.18.1: + resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.11.1: + resolution: {integrity: sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==} + engines: {node: '>=6.0.0'} + hasBin: true + + body-parser@2.3.0: + resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==} + engines: {node: '>=18'} + + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + conf@10.2.0: + resolution: {integrity: sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg==} + engines: {node: '>=12'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cosmiconfig@9.0.2: + resolution: {integrity: sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debounce-fn@4.0.0: + resolution: {integrity: sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ==} + engines: {node: '>=10'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + dedent@1.7.2: + resolution: {integrity: sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==} + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + + default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + + define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + + define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dot-prop@6.0.1: + resolution: {integrity: sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==} + engines: {node: '>=10'} + + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + electron-to-chromium@1.5.395: + resolution: {integrity: sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + enhanced-resolve@5.24.3: + resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + engines: {node: '>=10.13.0'} + + enquirer@2.4.1: + resolution: {integrity: sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==} + engines: {node: '>=8.6'} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + eventsource-parser@3.1.0: + resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + execa@5.1.1: + resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + engines: {node: '>=10'} + + execa@9.6.1: + resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} + engines: {node: ^18.19.0 || >=20.5.0} + + express-rate-limit@8.6.0: + resolution: {integrity: sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + figures@6.1.0: + resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} + engines: {node: '>=18'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + find-up@3.0.0: + resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==} + engines: {node: '>=6'} + + follow-redirects@1.16.0: + resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + fs-extra@11.3.6: + resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + fuzzysort@3.1.0: + resolution: {integrity: sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + + get-own-enumerable-keys@1.0.0: + resolution: {integrity: sha512-PKsK2FSrQCyxcGHsGrLDcK0lx+0Ke+6e8KFFozA9/fIQLhQzPaRvJFdcz7+Axg3jUH/Mq+NI4xa5u/UT2tQskA==} + engines: {node: '>=14.16'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + + get-stream@9.0.1: + resolution: {integrity: sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA==} + engines: {node: '>=18'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hono@4.12.31: + resolution: {integrity: sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + human-signals@2.1.0: + resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + engines: {node: '>=10.17.0'} + + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} + engines: {node: '>=18.18.0'} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-obj@2.0.0: + resolution: {integrity: sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==} + engines: {node: '>=8'} + + is-obj@3.0.0: + resolution: {integrity: sha512-IlsXEHOjtKhpN8r/tRFj2nDyTmHvcfNeu/nrRIcXE17ROeatXchkojffa1SpdqW4cr/Fj6QkEf/Gn4zf6KKvEQ==} + engines: {node: '>=12'} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + is-regexp@3.1.0: + resolution: {integrity: sha512-rbku49cWloU5bSMI+zaRaXdQHXnthP6DZ/vLnfdSKyL4zUzuWnomtOEiZZOd+ioQ+avFo/qau3KPTc7Fjy1uPA==} + engines: {node: '>=12'} + + is-stream@2.0.1: + resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + engines: {node: '>=8'} + + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} + + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + + is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.4: + resolution: {integrity: sha512-N8acGzVsQy6M/fjFcxtysNc4Q379TcM5dM/qKkNtsHFji88yANnXTr7BLeP75iPnFwBfQzM/jg2BZ9+HZrHCZA==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@7.0.3: + resolution: {integrity: sha512-7DE8mpG+/fVw+dTpjbxnx47TaMnDfOI1jwft9g1VybltZCduyRQPJPvc+zzKY9WPHxhPWczyFuYa6I8Mw4iU5A==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + locate-path@3.0.0: + resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==} + engines: {node: '>=6'} + + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.26.0: + resolution: {integrity: sha512-raglYVR2+VkMfJL158krjVmE+rV5ST2lzA/KQm1FRSjMHT4MnWaegHxoVEpmc2So3nOEhp9oGejJwAPX8MoAjg==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + + mimic-fn@3.1.0: + resolution: {integrity: sha512-Ysbi9uYW9hFyfrThdDEQuykN4Ey6BuwPD2kpI5ES/nFTDn/98yxYNLZJcgUAKPT/mcrLLKaGzJR9YVxJrIdASQ==} + engines: {node: '>=8'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + + npm-run-path@4.0.1: + resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + engines: {node: '>=8'} + + npm-run-path@6.0.0: + resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==} + engines: {node: '>=18'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-treeify@1.1.33: + resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==} + engines: {node: '>= 10'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + + open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@3.0.0: + resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==} + engines: {node: '>=6'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-exists@3.0.0: + resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} + engines: {node: '>=4'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + pkg-up@3.1.0: + resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==} + engines: {node: '>=8'} + + postcss-selector-parser@7.1.4: + resolution: {integrity: sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==} + engines: {node: '>=4'} + + postcss@8.5.22: + resolution: {integrity: sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==} + engines: {node: ^10 || ^12 || >=14} + + powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + + pretty-ms@9.3.0: + resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} + engines: {node: '>=18'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + qs@6.15.3: + resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + engines: {node: '>=0.6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + radix-ui@1.6.5: + resolution: {integrity: sha512-HVHPWDoOXBYxxiwvWR45KPcmvrjoa/NTFUbPbGmYoZv7BFYOIpZKiPQZWcpFJTX5z4LeoGenGCUgoafdYHuPWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + range-parser@1.3.0: + resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-hook-form@7.82.0: + resolution: {integrity: sha512-Zw/uFZ2dO+02GHlBn7JFGn8kZJ7LdM33B/0BXOovzFay+CMhf94JMw5BVu+F1tVkUKjNvBuaE3fz5BJhga10Tg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-router-dom@7.18.1: + resolution: {integrity: sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.1: + resolution: {integrity: sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + recast@0.23.12: + resolution: {integrity: sha512-dEWRjcINDu/F4l2dYx57ugBtD7HV9KXESyxhzw/MqWLeglJrsjJKqACPyUPg+6AF8mIgm+Zi0dZ3ACoIg+QtpA==} + engines: {node: '>= 4'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown@1.1.5: + resolution: {integrity: sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shadcn@4.14.1: + resolution: {integrity: sha512-Xb4r150oGNexMBxUnWPG1emcaUlFbhzMOJubdA+k0NTkmhgBBycFZjTz9Aw2yQbpDR6QnIZbk234N20s/s7s9A==} + engines: {node: '>=20.18.1'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.1: + resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==} + engines: {node: '>= 0.4'} + + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-object@5.0.0: + resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} + engines: {node: '>=14.16'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-final-newline@2.0.0: + resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + engines: {node: '>=6'} + + strip-final-newline@4.0.0: + resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} + engines: {node: '>=18'} + + systeminformation@5.33.1: + resolution: {integrity: sha512-DEN6ICHk3Tk0Uf/hrAHh7xlt7iL5CJFBtPZinA0H62DrGG/KPKqq/Nzj6lCXPS4Ay/sf/14zNnk9LpqKzBIc+w==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.3: + resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-morph@26.0.0: + resolution: {integrity: sha512-ztMO++owQnz8c/gIENcM9XfCEzgoGphTv+nKpYNM1bgsdOVC/jRZuEBf6N+mLLDNg68Kl+GgUZfOySaRiG1/Ug==} + + tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + + unicorn-magic@0.3.0: + resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} + engines: {node: '>=18'} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + validate-npm-package-name@7.0.2: + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} + engines: {node: ^20.17.0 || >=22.9.0} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + vite@8.1.5: + resolution: {integrity: sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.3.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + engines: {node: ^16.13.0 || >=18.0.0} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-spinner@1.2.2: + resolution: {integrity: sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==} + engines: {node: '>=18.19'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + 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/core@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.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-annotate-as-pure@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.7 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-create-class-features-plugin@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/traverse': 7.29.7 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-member-expression-to-functions@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-optimise-call-expression@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/helper-plugin-utils@7.29.7': {} + + '@babel/helper-replace-supers@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-member-expression-to-functions': 7.29.7 + '@babel/helper-optimise-call-expression': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-skip-transparent-expression-wrappers@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/plugin-syntax-jsx@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-syntax-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + + '@babel/plugin-transform-modules-commonjs@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-annotate-as-pure': 7.29.7 + '@babel/helper-create-class-features-plugin': 7.29.7(@babel/core@7.29.7) + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 + '@babel/plugin-syntax-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/preset-typescript@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-plugin-utils': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + transitivePeerDependencies: + - supports-color + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@dotenvx/dotenvx@1.75.1': + dependencies: + '@dotenvx/primitives': 0.8.0 + commander: 11.1.0 + conf: 10.2.0 + dotenv: 17.4.2 + enquirer: 2.4.1 + env-paths: 2.2.1 + execa: 5.1.1 + fdir: 6.5.0(picomatch@4.0.5) + ignore: 5.3.2 + object-treeify: 1.1.33 + open: 8.4.2 + picomatch: 4.0.5 + systeminformation: 5.33.1 + undici: 7.28.0 + which: 4.0.0 + yocto-spinner: 1.2.2 + + '@dotenvx/primitives@0.8.0': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + + '@fontsource-variable/geist@5.3.0': {} + + '@hono/node-server@1.19.14(hono@4.12.31)': + dependencies: + hono: 4.12.31 + + '@hookform/resolvers@5.4.0(react-hook-form@7.82.0(react@19.2.8))': + dependencies: + '@standard-schema/utils': 0.3.0 + react-hook-form: 7.82.0(react@19.2.8) + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.31) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.0 + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) + hono: 4.12.31 + jose: 6.2.4 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.3 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/types@0.139.0': {} + + '@radix-ui/number@1.1.3': {} + + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-accessible-icon@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-accordion@1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-alert-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-arrow@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-aspect-ratio@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-avatar@1.2.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-checkbox@1.3.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collapsible@1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-context@1.2.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dialog@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-direction@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-form@0.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-label': 2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-hover-card@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-label@2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menu@2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-menubar@1.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-navigation-menu@1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-one-time-password-field@0.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-password-toggle-field@0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popover@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-progress@1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-radio-group@1.4.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-scroll-area@1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-select@2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-separator@1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slider@1.4.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/number': 1.1.3 + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-previous': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.1(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-switch@1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tabs@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toast@1.2.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle-group@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toggle@1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-toolbar@1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-tooltip@1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-id': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-escape-keydown@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-previous@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.3': {} + + '@rolldown/binding-android-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-arm64@1.1.5': + optional: true + + '@rolldown/binding-darwin-x64@1.1.5': + optional: true + + '@rolldown/binding-freebsd-x64@1.1.5': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.1.5': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.1.5': + optional: true + + '@rolldown/binding-linux-x64-musl@1.1.5': + optional: true + + '@rolldown/binding-openharmony-arm64@1.1.5': + optional: true + + '@rolldown/binding-wasm32-wasi@1.1.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.1.5': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.1.5': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sec-ant/readable-stream@0.4.1': {} + + '@sindresorhus/merge-streams@4.0.0': {} + + '@standard-schema/utils@0.3.0': {} + + '@tailwindcss/node@4.3.3': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.24.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.3 + + '@tailwindcss/oxide-android-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.3': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.3': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.3': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.3': + optional: true + + '@tailwindcss/oxide@4.3.3': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-arm64': 4.3.3 + '@tailwindcss/oxide-darwin-x64': 4.3.3 + '@tailwindcss/oxide-freebsd-x64': 4.3.3 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.3 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.3 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.3 + '@tailwindcss/oxide-linux-x64-musl': 4.3.3 + '@tailwindcss/oxide-wasm32-wasi': 4.3.3 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.3 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.3 + + '@tailwindcss/vite@4.3.3(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.3 + '@tailwindcss/oxide': 4.3.3 + tailwindcss: 4.3.3 + vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0) + + '@ts-morph/common@0.27.0': + dependencies: + fast-glob: 3.3.3 + minimatch: 10.2.5 + path-browserify: 1.0.1 + + '@tybys/wasm-util@0.10.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@types/validate-npm-package-name@4.0.2': {} + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitejs/plugin-react@6.0.4(vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.1.5(@types/node@26.1.1)(jiti@2.7.0) + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + ajv-formats@2.1.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-colors@4.1.3: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + argparse@2.0.1: {} + + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + + ast-types@0.16.1: + dependencies: + tslib: 2.8.1 + + asynckit@0.4.0: {} + + atomically@1.7.0: {} + + axios@1.18.1: + dependencies: + follow-redirects: 1.16.0 + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.11.1: {} + + body-parser@2.3.0: + dependencies: + bytes: 3.1.2 + content-type: 2.0.0 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + on-finished: 2.4.1 + qs: 6.15.3 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.1 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.395 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + + bundle-name@4.1.0: + dependencies: + run-applescript: 7.1.0 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001806: {} + + chalk@5.6.2: {} + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + + clsx@2.1.1: {} + + code-block-writer@13.0.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + commander@11.1.0: {} + + commander@14.0.3: {} + + conf@10.2.0: + dependencies: + ajv: 8.20.0 + ajv-formats: 2.1.1(ajv@8.20.0) + atomically: 1.7.0 + debounce-fn: 4.0.0 + dot-prop: 6.0.1 + env-paths: 2.2.1 + json-schema-typed: 7.0.3 + onetime: 5.1.2 + pkg-up: 3.1.0 + semver: 7.8.5 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + convert-source-map@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cookie@1.1.1: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cosmiconfig@9.0.2(typescript@7.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + parse-json: 5.2.0 + optionalDependencies: + typescript: 7.0.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debounce-fn@4.0.0: + dependencies: + mimic-fn: 3.1.0 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + dedent@1.7.2: {} + + deepmerge@4.3.1: {} + + default-browser-id@5.0.1: {} + + default-browser@5.5.0: + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + + define-lazy-prop@2.0.0: {} + + define-lazy-prop@3.0.0: {} + + delayed-stream@1.0.0: {} + + depd@2.0.0: {} + + detect-libc@2.1.2: {} + + detect-node-es@1.1.0: {} + + diff@8.0.4: {} + + dot-prop@6.0.1: + dependencies: + is-obj: 2.0.0 + + dotenv@17.4.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + electron-to-chromium@1.5.395: {} + + emoji-regex@10.6.0: {} + + encodeurl@2.0.0: {} + + enhanced-resolve@5.24.3: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + enquirer@2.4.1: + dependencies: + ansi-colors: 4.1.3 + strip-ansi: 6.0.1 + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + escalade@3.2.0: {} + + escape-html@1.0.3: {} + + esprima@4.0.1: {} + + etag@1.8.1: {} + + eventsource-parser@3.1.0: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.0 + + execa@5.1.1: + dependencies: + cross-spawn: 7.0.6 + get-stream: 6.0.1 + human-signals: 2.1.0 + is-stream: 2.0.1 + merge-stream: 2.0.0 + npm-run-path: 4.0.1 + onetime: 5.1.2 + signal-exit: 3.0.7 + strip-final-newline: 2.0.0 + + execa@9.6.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + cross-spawn: 7.0.6 + figures: 6.1.0 + get-stream: 9.0.1 + human-signals: 8.0.1 + is-plain-obj: 4.1.0 + is-stream: 4.0.1 + npm-run-path: 6.0.0 + pretty-ms: 9.3.0 + signal-exit: 4.1.0 + strip-final-newline: 4.0.0 + yoctocolors: 2.1.2 + + express-rate-limit@8.6.0(express@5.2.1): + dependencies: + debug: 4.4.3 + express: 5.2.1 + ip-address: 10.2.0 + transitivePeerDependencies: + - supports-color + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.3.0 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.3 + range-parser: 1.3.0 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.4: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + figures@6.1.0: + dependencies: + is-unicode-supported: 2.1.0 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + find-up@3.0.0: + dependencies: + locate-path: 3.0.0 + + follow-redirects@1.16.0: {} + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + fs-extra@11.3.6: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + fuzzysort@3.1.0: {} + + gensync@1.0.0-beta.2: {} + + get-east-asian-width@1.6.0: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-nonce@1.0.1: {} + + get-own-enumerable-keys@1.0.0: {} + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + get-stream@6.0.1: {} + + get-stream@9.0.1: + dependencies: + '@sec-ant/readable-stream': 0.4.1 + is-stream: 4.0.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hono@4.12.31: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + human-signals@2.1.0: {} + + human-signals@8.0.1: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + ignore@5.3.2: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-arrayish@0.2.1: {} + + is-docker@2.2.1: {} + + is-docker@3.0.0: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-in-ssh@1.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-interactive@2.0.0: {} + + is-number@7.0.0: {} + + is-obj@2.0.0: {} + + is-obj@3.0.0: {} + + is-plain-obj@4.1.0: {} + + is-promise@4.0.0: {} + + is-regexp@3.1.0: {} + + is-stream@2.0.1: {} + + is-stream@4.0.1: {} + + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + + is-wsl@2.2.0: + dependencies: + is-docker: 2.2.1 + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + isexe@2.0.0: {} + + isexe@3.1.5: {} + + jiti@2.7.0: {} + + jose@6.2.4: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json-schema-typed@7.0.3: {} + + json-schema-typed@8.0.2: {} + + json5@2.2.3: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lines-and-columns@1.2.4: {} + + locate-path@3.0.0: + dependencies: + p-locate: 3.0.0 + path-exists: 3.0.0 + + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.26.0(react@19.2.8): + dependencies: + react: 19.2.8 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + merge-stream@2.0.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + mime-db@1.52.0: {} + + mime-db@1.54.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + mimic-fn@2.1.0: {} + + mimic-fn@3.1.0: {} + + mimic-function@5.0.1: {} + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + minimist@1.2.8: {} + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + negotiator@1.0.0: {} + + next-themes@0.4.6(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + node-releases@2.0.51: {} + + npm-run-path@4.0.1: + dependencies: + path-key: 3.1.1 + + npm-run-path@6.0.0: + dependencies: + path-key: 4.0.0 + unicorn-magic: 0.3.0 + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-treeify@1.1.33: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + onetime@5.1.2: + dependencies: + mimic-fn: 2.1.0 + + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + open@11.0.0: + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + + open@8.4.2: + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@3.0.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.7 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + parse-ms@4.0.0: {} + + parseurl@1.3.3: {} + + path-browserify@1.0.1: {} + + path-exists@3.0.0: {} + + path-key@3.1.1: {} + + path-key@4.0.0: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + pkce-challenge@5.0.1: {} + + pkg-up@3.1.0: + dependencies: + find-up: 3.0.0 + + postcss-selector-parser@7.1.4: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.22: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + powershell-utils@0.1.0: {} + + pretty-ms@9.3.0: + dependencies: + parse-ms: 4.0.0 + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + proxy-from-env@2.1.0: {} + + qs@6.15.3: + dependencies: + es-define-property: 1.0.1 + side-channel: 1.1.1 + + queue-microtask@1.2.3: {} + + radix-ui@1.6.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-accessible-icon': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-accordion': 1.2.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-alert-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-arrow': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-aspect-ratio': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-avatar': 1.2.4(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-checkbox': 1.3.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collapsible': 1.1.18(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-collection': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context-menu': 2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dialog': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-direction': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-dropdown-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-form': 0.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-hover-card': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-label': 2.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menu': 2.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-menubar': 1.1.22(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-navigation-menu': 1.2.20(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-one-time-password-field': 0.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-password-toggle-field': 0.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popover': 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-popper': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-portal': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-presence': 1.1.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.8(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-progress': 1.1.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-radio-group': 1.4.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-scroll-area': 1.2.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-select': 2.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-separator': 1.1.13(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slider': 1.4.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.1(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-switch': 1.3.5(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tabs': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toast': 1.2.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle': 1.1.16(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toggle-group': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-toolbar': 1.1.17(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-tooltip': 1.2.14(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-escape-keydown': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.9(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + range-parser@1.3.0: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.3 + unpipe: 1.0.0 + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-hook-form@7.82.0(react@19.2.8): + dependencies: + react: 19.2.8 + + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + + react-router-dom@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react@19.2.8: {} + + recast@0.23.12: + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.8.1 + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + reusify@1.1.0: {} + + rolldown@1.1.5: + dependencies: + '@oxc-project/types': 0.139.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.1.5 + '@rolldown/binding-darwin-arm64': 1.1.5 + '@rolldown/binding-darwin-x64': 1.1.5 + '@rolldown/binding-freebsd-x64': 1.1.5 + '@rolldown/binding-linux-arm-gnueabihf': 1.1.5 + '@rolldown/binding-linux-arm64-gnu': 1.1.5 + '@rolldown/binding-linux-arm64-musl': 1.1.5 + '@rolldown/binding-linux-ppc64-gnu': 1.1.5 + '@rolldown/binding-linux-s390x-gnu': 1.1.5 + '@rolldown/binding-linux-x64-gnu': 1.1.5 + '@rolldown/binding-linux-x64-musl': 1.1.5 + '@rolldown/binding-openharmony-arm64': 1.1.5 + '@rolldown/binding-wasm32-wasi': 1.1.5 + '@rolldown/binding-win32-arm64-msvc': 1.1.5 + '@rolldown/binding-win32-x64-msvc': 1.1.5 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + run-applescript@7.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safer-buffer@2.1.2: {} + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.5: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.3.0 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + set-cookie-parser@2.7.2: {} + + setprototypeof@1.2.0: {} + + shadcn@4.14.1(typescript@7.0.2): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/plugin-transform-typescript': 7.29.7(@babel/core@7.29.7) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7) + '@dotenvx/dotenvx': 1.75.1 + '@modelcontextprotocol/sdk': 1.29.0(zod@3.25.76) + '@types/validate-npm-package-name': 4.0.2 + browserslist: 4.28.7 + commander: 14.0.3 + cosmiconfig: 9.0.2(typescript@7.0.2) + dedent: 1.7.2 + deepmerge: 4.3.1 + diff: 8.0.4 + execa: 9.6.1 + fast-glob: 3.3.3 + fs-extra: 11.3.6 + fuzzysort: 3.1.0 + kleur: 4.1.5 + open: 11.0.0 + ora: 8.2.0 + postcss: 8.5.22 + postcss-selector-parser: 7.1.4 + prompts: 2.4.2 + recast: 0.23.12 + stringify-object: 5.0.0 + tailwind-merge: 3.6.0 + ts-morph: 26.0.0 + tsconfig-paths: 4.2.0 + undici: 7.28.0 + validate-npm-package-name: 7.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - '@cfworker/json-schema' + - babel-plugin-macros + - supports-color + - typescript + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + signal-exit@3.0.7: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + sonner@2.0.7(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + source-map-js@1.2.1: {} + + source-map@0.6.1: {} + + statuses@2.0.2: {} + + stdin-discarder@0.2.2: {} + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-object@5.0.0: + dependencies: + get-own-enumerable-keys: 1.0.0 + is-obj: 3.0.0 + is-regexp: 3.1.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-bom@3.0.0: {} + + strip-final-newline@2.0.0: {} + + strip-final-newline@4.0.0: {} + + systeminformation@5.33.1: {} + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.3: {} + + tapable@2.3.3: {} + + tiny-invariant@1.3.3: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + toidentifier@1.0.1: {} + + ts-morph@26.0.0: + dependencies: + '@ts-morph/common': 0.27.0 + code-block-writer: 13.0.3 + + tsconfig-paths@4.2.0: + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + tw-animate-css@1.4.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + undici@7.28.0: {} + + unicorn-magic@0.3.0: {} + + universalify@2.0.1: {} + + unpipe@1.0.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + util-deprecate@1.0.2: {} + + validate-npm-package-name@7.0.2: {} + + vary@1.1.2: {} + + vite@8.1.5(@types/node@26.1.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.22 + rolldown: 1.1.5 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.1.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + which@4.0.0: + dependencies: + isexe: 3.1.5 + + wrappy@1.0.2: {} + + wsl-utils@0.3.1: + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + + yallist@3.1.1: {} + + yocto-spinner@1.2.2: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod@3.25.76: {} + + zod@4.4.3: {} diff --git a/web/pnpm-workspace.yaml b/web/pnpm-workspace.yaml new file mode 100644 index 0000000..e7a0540 --- /dev/null +++ b/web/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +# Keep web/ as a self-contained pnpm project, isolated from the backend +# workspace root one level up. Its own node_modules / lockfile live here. +packages: [] diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..aa6c770 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,14 @@ +import { RouterProvider } from "react-router-dom"; + +import { Toaster } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { router } from "@/router"; + +export default function App() { + return ( + + + + + ); +} diff --git a/web/src/components/layout/app-layout.tsx b/web/src/components/layout/app-layout.tsx new file mode 100644 index 0000000..9cf54b3 --- /dev/null +++ b/web/src/components/layout/app-layout.tsx @@ -0,0 +1,41 @@ +import { Outlet, useLocation } from "react-router-dom"; + +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar"; +import { Separator } from "@/components/ui/separator"; +import { AppSidebar } from "@/components/layout/app-sidebar"; +import { navItems } from "@/config/nav"; + +function usePageTitle(): string { + const { pathname } = useLocation(); + // Longest matching nav prefix wins (so /forms/123 still resolves to 表單管理). + const match = [...navItems] + .filter((item) => + item.end ? pathname === item.url : pathname.startsWith(item.url), + ) + .sort((a, b) => b.url.length - a.url.length)[0]; + return match?.title ?? "Form Platform"; +} + +export function AppLayout() { + const title = usePageTitle(); + + return ( + + + +
+ + +

{title}

+
+
+ +
+
+
+ ); +} diff --git a/web/src/components/layout/app-sidebar.tsx b/web/src/components/layout/app-sidebar.tsx new file mode 100644 index 0000000..a816ed7 --- /dev/null +++ b/web/src/components/layout/app-sidebar.tsx @@ -0,0 +1,72 @@ +import { NavLink } from "react-router-dom"; +import { LayoutGrid } from "lucide-react"; + +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { navItems } from "@/config/nav"; +import { UserMenu } from "@/components/layout/user-menu"; + +// NavLink sets aria-current="page" on the active route; key the shadcn active +// styling off that so we don't have to thread isActive through render-props. +const activeLink = + "aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium aria-[current=page]:text-sidebar-accent-foreground"; + +export function AppSidebar() { + return ( + + + + + + +
+ +
+
+ Form Platform + + 管理後台 + +
+
+
+
+
+
+ + + + 導覽 + + + {navItems.map((item) => ( + + + + + {item.title} + + + + ))} + + + + + + + + +
+ ); +} diff --git a/web/src/components/layout/user-menu.tsx b/web/src/components/layout/user-menu.tsx new file mode 100644 index 0000000..59fbf4c --- /dev/null +++ b/web/src/components/layout/user-menu.tsx @@ -0,0 +1,102 @@ +import { ChevronsUpDown, LogOut, User } from "lucide-react"; +import { useNavigate } from "react-router-dom"; + +import { clearToken, api } from "@/lib/api"; + +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from "@/components/ui/sidebar"; + +// Placeholder user until the /auth/me endpoint is wired up. +const currentUser = { + name: "訪客", + email: "guest@example.com", +}; + +export function UserMenu() { + const { isMobile } = useSidebar(); + const navigate = useNavigate(); + + const initials = currentUser.name.slice(0, 2).toUpperCase(); + + async function handleLogout() { + try { + // 呼叫後端登出 API(可選,失敗也繼續) + await api.post("/auth/logout"); + } catch { + // 忽略錯誤 + } finally { + // 清除本地 token + clearToken(); + // 導向登入頁 + navigate("/login"); + } + } + + return ( + + + + + + + + {initials} + + +
+ + {currentUser.name} + + + {currentUser.email} + +
+ +
+
+ + +
+ {currentUser.name} + + {currentUser.email} + +
+
+ + navigate("/settings")}> + + 個人設定 + + + + + 登出 + +
+
+
+
+ ); +} diff --git a/web/src/components/page-placeholder.tsx b/web/src/components/page-placeholder.tsx new file mode 100644 index 0000000..bc2b38f --- /dev/null +++ b/web/src/components/page-placeholder.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from "react"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +type PagePlaceholderProps = { + title: string; + description?: string; + children?: ReactNode; +}; + +/** Generic placeholder shown while a feature module is not yet implemented. */ +export function PagePlaceholder({ + title, + description, + children, +}: PagePlaceholderProps) { + return ( + + + {title} + {description ? {description} : null} + + + {children ?? "此頁面尚未實作,先放置佔位內容。"} + + + ); +} diff --git a/web/src/components/ui/avatar.tsx b/web/src/components/ui/avatar.tsx new file mode 100644 index 0000000..99f3ed2 --- /dev/null +++ b/web/src/components/ui/avatar.tsx @@ -0,0 +1,110 @@ +import * as React from "react" +import { Avatar as AvatarPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Avatar({ + className, + size = "default", + ...props +}: React.ComponentProps & { + size?: "default" | "sm" | "lg" +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { + return ( + svg]:hidden", + "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", + "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..cacff11 --- /dev/null +++ b/web/src/components/ui/badge.tsx @@ -0,0 +1,49 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80", + secondary: + "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", + destructive: + "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", + outline: + "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", + ghost: + "hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50", + link: "text-primary underline-offset-4 hover:underline", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Badge({ + className, + variant = "default", + asChild = false, + ...props +}: React.ComponentProps<"span"> & + VariantProps & { asChild?: boolean }) { + const Comp = asChild ? Slot.Root : "span" + + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/web/src/components/ui/button.tsx b/web/src/components/ui/button.tsx new file mode 100644 index 0000000..75b8c3d --- /dev/null +++ b/web/src/components/ui/button.tsx @@ -0,0 +1,67 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/lib/utils" + +const buttonVariants = cva( + "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/80", + outline: + "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground", + ghost: + "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50", + destructive: + "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: + "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", + lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2", + icon: "size-8", + "icon-xs": + "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", + "icon-sm": + "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg", + "icon-lg": "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot.Root : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/web/src/components/ui/card.tsx b/web/src/components/ui/card.tsx new file mode 100644 index 0000000..4458dae --- /dev/null +++ b/web/src/components/ui/card.tsx @@ -0,0 +1,103 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ + className, + size = "default", + ...props +}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl", + className + )} + {...props} + /> + ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..cec7a77 --- /dev/null +++ b/web/src/components/ui/checkbox.tsx @@ -0,0 +1,31 @@ +import * as React from "react" +import { Checkbox as CheckboxPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon } from "lucide-react" + +function Checkbox({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ) +} + +export { Checkbox } diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx new file mode 100644 index 0000000..d9aecca --- /dev/null +++ b/web/src/components/ui/dialog.tsx @@ -0,0 +1,168 @@ +"use client" + +import * as React from "react" +import { Dialog as DialogPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { XIcon } from "lucide-react" + +function Dialog({ + ...props +}: React.ComponentProps) { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, + DialogTitle, + DialogTrigger, +} diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..c263ad5 --- /dev/null +++ b/web/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,269 @@ +"use client" + +import * as React from "react" +import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { CheckIcon, ChevronRightIcon } from "lucide-react" + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = "start", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: "default" | "destructive" +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/web/src/components/ui/form.tsx b/web/src/components/ui/form.tsx new file mode 100644 index 0000000..8e05ec1 --- /dev/null +++ b/web/src/components/ui/form.tsx @@ -0,0 +1,164 @@ +import * as React from "react" +import { Slot } from "radix-ui" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue, +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +function useFormField() { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext( + {} as FormItemContextValue, +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +