diff --git a/.github/workflows/database_cleanup.yml b/.github/workflows/database_cleanup.yml index f7a9e26..f2fe1d1 100644 --- a/.github/workflows/database_cleanup.yml +++ b/.github/workflows/database_cleanup.yml @@ -3,6 +3,10 @@ on: pull_request: types: - closed + paths-ignore: + - .gitignore + - .github/** + - README.md workflow_dispatch: inputs: pr_number: @@ -13,6 +17,8 @@ on: jobs: cleanup_database: runs-on: ubuntu-latest + env: + TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} steps: - name: Install Turso CLI run: | @@ -30,8 +36,5 @@ jobs: echo "DB_NAME=$DB_NAME" >> $GITHUB_ENV - name: Destroy Turso Database - env: - TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} - DB_NAME: ${{ env.DB_NAME }} run: | turso db destroy "$DB_NAME" --yes || echo "Database already gone or never existed" diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index c4f0dd5..7b9542e 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -15,7 +15,7 @@ permissions: concurrency: group: deployment-dev - cancel-in-progress: true + cancel-in-progress: false jobs: deploy: @@ -36,7 +36,7 @@ jobs: TURSO_DATABASE_URL: ${{ secrets.DEV_DB_URL }} TURSO_AUTH_TOKEN: ${{ secrets.DEV_DB_TOKEN }} run: | - npx drizzle-kit push + npx drizzle-kit migrate - name: Make Development Deployment env: diff --git a/.github/workflows/pr_preview.yml b/.github/workflows/pr_preview.yml index 3cb7d00..2b75cf8 100644 --- a/.github/workflows/pr_preview.yml +++ b/.github/workflows/pr_preview.yml @@ -17,7 +17,7 @@ on: type: number concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.inputs.pr_number }} cancel-in-progress: true permissions: @@ -25,7 +25,29 @@ permissions: contents: read jobs: + check-migrations: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Check Migrations + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle/)" ]; then + echo "⚠️ Missing migration files! Please run npx drizzle-kit generate..." + exit 1 + fi + deploy: + needs: [check-migrations] environment: Preview runs-on: ubuntu-latest steps: @@ -59,7 +81,7 @@ jobs: TURSO_API_TOKEN: ${{ secrets.TURSO_API_TOKEN }} run: | # Create a branch from the dev database with the generated safe name - turso db create ${{ env.SAFE_BRANCH_NAME }} --from-db dev || true + turso db show ${{ env.SAFE_BRANCH_NAME }} &> /dev/null || turso db create ${{ env.SAFE_BRANCH_NAME }} --from-db dev - name: Get Database Credentials env: @@ -82,7 +104,7 @@ jobs: TURSO_AUTH_TOKEN: ${{ env.DB_TOKEN }} run: | # Push changes to the created database - npx drizzle-kit push + npx drizzle-kit migrate - name: Make Preview Deployment id: vercel-deployment @@ -92,17 +114,17 @@ jobs: VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} run: | # Make a preview deployment with the created database - DEPLOYMENT_URL=$(npx vercel deploy --target preview --token ${{ secrets.VERCEL_TOKEN }} \ - --build-env TURSO_DATABASE_URL=${{ env.DB_URL }} \ - --build-env TURSO_AUTH_TOKEN=${{ env.DB_TOKEN }} \ - --env TURSO_DATABASE_URL=${{ env.DB_URL }} \ - --env TURSO_AUTH_TOKEN=${{ env.DB_TOKEN }} \ + DEPLOYMENT_URL=$(npx vercel deploy --target preview --token "$VERCEL_TOKEN" \ + --build-env TURSO_DATABASE_URL="$DB_URL" \ + --build-env TURSO_AUTH_TOKEN="$DB_TOKEN" \ + --env TURSO_DATABASE_URL="$DB_URL" \ + --env TURSO_AUTH_TOKEN="$DB_TOKEN" \ --yes --logs) echo "DEPLOYMENT_URL=$DEPLOYMENT_URL" >> $GITHUB_ENV - name: Comment on PR - if: always() + if: always() && env.PR_NUM != '' uses: actions/github-script@v8 env: DEPLOYMENT_URL: ${{ env.DEPLOYMENT_URL }} diff --git a/.github/workflows/production_deploy.yml b/.github/workflows/production_deploy.yml index f369ed6..f6b3d58 100644 --- a/.github/workflows/production_deploy.yml +++ b/.github/workflows/production_deploy.yml @@ -10,8 +10,34 @@ on: - README.md workflow_dispatch: +concurrency: + group: deployment-production + cancel-in-progress: false + jobs: + check-migrations: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "npm" + + - run: npm ci + + - name: Check Migrations + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle/)" ]; then + echo "⚠️ Missing migration files! Please run npx drizzle-kit generate..." + exit 1 + fi + deploy: + needs: [check-migrations] runs-on: ubuntu-latest steps: - name: Checkout code @@ -37,4 +63,4 @@ jobs: VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }} VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }} run: | - npx vercel deploy --prod --yes --token ${{ secrets.VERCEL_TOKEN }} + npx vercel deploy --prod --yes --token "$VERCEL_TOKEN" diff --git a/package-lock.json b/package-lock.json index e971780..d8ff69f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "next-themes": "^0.4.6", "nodemailer": "^7.0.10", "qrcode": "^1.5.4", + "radix-ui": "^1.4.3", "react": "18.3.1", "react-dom": "18.3.1", "react-downloadfile-hook": "^1.0.3", @@ -3682,19 +3683,25 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, "node_modules/@radix-ui/primitive": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, - "node_modules/@radix-ui/react-arrow": { + "node_modules/@radix-ui/react-accessible-icon": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", - "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accessible-icon/-/react-accessible-icon-1.1.7.tgz", + "integrity": "sha512-XM+E4WXl0OqUJFovy6GjmxxFyx9opfCAIUku4dlKRd5YEPqt4kALOkQOp0Of6reHuUkJuiPBEc5k0o4z4lTC8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.3" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3711,13 +3718,21 @@ } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", - "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -3734,61 +3749,59 @@ } } }, - "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@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 } } }, - "node_modules/@radix-ui/react-aspect-ratio": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", - "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", + "node_modules/@radix-ui/react-accordion/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-compose-refs": "1.1.2" }, "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" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-checkbox": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", - "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", + "integrity": "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "@radix-ui/react-use-previous": "1.1.1", - "@radix-ui/react-use-size": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -3805,7 +3818,7 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3828,7 +3841,7 @@ } } }, - "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3846,16 +3859,13 @@ } } }, - "node_modules/@radix-ui/react-collection": { + "node_modules/@radix-ui/react-arrow": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", - "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -3872,7 +3882,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -3895,7 +3905,7 @@ } } }, - "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-arrow/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -3913,56 +3923,40 @@ } } }, - "node_modules/@radix-ui/react-compose-refs": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", - "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.8.tgz", + "integrity": "sha512-5nZrJTF7gH+e0nZS7/QxFz6tJV4VimhQb1avEgtsJxvvIp5JilL+c58HICsKzPxghdwaDt48hEfPM1au4zGy+w==", "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-context": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", - "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", - "license": "MIT", "peerDependencies": { "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + "@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 } } }, - "node_modules/@radix-ui/react-dialog": { - "version": "1.1.15", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", - "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", "license": "MIT", "dependencies": { - "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-slot": "1.2.3", - "@radix-ui/react-use-controllable-state": "1.2.2", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -3979,7 +3973,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4002,7 +3996,7 @@ } } }, - "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-avatar/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4020,32 +4014,20 @@ } } }, - "node_modules/@radix-ui/react-direction": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", - "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-dismissable-layer": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", - "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-escape-keydown": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4062,7 +4044,7 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4085,7 +4067,7 @@ } } }, - "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-checkbox/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4103,19 +4085,20 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", - "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", @@ -4132,7 +4115,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4155,7 +4138,7 @@ } } }, - "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collapsible/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4173,30 +4156,16 @@ } } }, - "node_modules/@radix-ui/react-focus-guards": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", - "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-focus-scope": { + "node_modules/@radix-ui/react-collection": { "version": "1.1.7", - "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", - "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4213,7 +4182,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4236,7 +4205,7 @@ } } }, - "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4254,14 +4223,11 @@ } } }, - "node_modules/@radix-ui/react-id": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", - "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", "license": "MIT", - "dependencies": { - "@radix-ui/react-use-layout-effect": "1.1.1" - }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" @@ -4272,53 +4238,33 @@ } } }, - "node_modules/@radix-ui/react-label": { - "version": "2.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", - "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", "license": "MIT", - "dependencies": { - "@radix-ui/react-primitive": "2.1.4" - }, "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" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-menu": { - "version": "2.1.16", - "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", - "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.16.tgz", + "integrity": "sha512-O8morBEW+HsVG28gYDZPTrT9UUovQUlJue5YO836tiTJhuIWBm/zQHc7j388sHWtdH/xUZurK9olD2+pcqx5ww==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", - "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-dismissable-layer": "1.1.11", - "@radix-ui/react-focus-guards": "1.1.3", - "@radix-ui/react-focus-scope": "1.1.7", - "@radix-ui/react-id": "1.1.1", - "@radix-ui/react-popper": "1.2.8", - "@radix-ui/react-portal": "1.1.9", - "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-roving-focus": "1.1.11", - "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", - "aria-hidden": "^1.2.4", - "react-remove-scroll": "^2.6.3" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4335,7 +4281,7 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4358,7 +4304,978 @@ } } }, - "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.16.tgz", + "integrity": "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-focus-scope/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-form": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-form/-/react-form-0.1.8.tgz", + "integrity": "sha512-QM70k4Zwjttifr5a4sZFts9fn8FzHYvQ5PiB19O2HsYibaHSVt9fH9rzB0XZo/YcM+b7t/p7lYCT/F5eOeF5yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-form/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.15.tgz", + "integrity": "sha512-qgTkjNT1CfKMoP0rcasmlH2r1DAiYicWsDsufxl940sT2wHNEWWv6FMWIQXWhVdmC1d/HYfbhQx60KYyAtKxjg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-hover-card/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.8.tgz", + "integrity": "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.16.tgz", + "integrity": "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.16", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.16.tgz", + "integrity": "sha512-EB1FktTz5xRRi2Er974AUQZWg2yVBb1yjip38/lgwtCVRd3a+maUoGHN/xs9Yv8SY8QwbSEb+YrxGadVWbEutA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-menubar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.14.tgz", + "integrity": "sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-navigation-menu/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-one-time-password-field/-/react-one-time-password-field-0.1.8.tgz", + "integrity": "sha512-ycS4rbwURavDPVjCb5iS3aG4lURFDILi6sKI/WITUMZ13gMmn/xGjpLoqBAalhJaDk8I3UbCM5GzKHrnzwHbvg==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-one-time-password-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-password-toggle-field/-/react-password-toggle-field-0.1.3.tgz", + "integrity": "sha512-/UuCrDBWravcaMix4TdT+qlNdVwOM1Nck9kWx/vafXsdfj1ChfhOdfi3cy9SGBpWgTXwYCuboT/oYpJy3clqfw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-is-hydrated": "0.1.0" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-password-toggle-field/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4382,16 +5299,825 @@ "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.0.0", - "@radix-ui/react-arrow": "1.1.7", + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", + "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.8.tgz", + "integrity": "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", + "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.10.tgz", + "integrity": "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-scroll-area/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", + "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.4" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.6.tgz", + "integrity": "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz", + "integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-switch/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", + "integrity": "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.15.tgz", + "integrity": "sha512-3OSz3TacUWy4WtOXV38DggwxoqJK4+eDkNMl5Z/MJZaoUPaP4/9lf81xXMe1I2ReTAptverZUpbPY4wWwWyL5g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", - "@radix-ui/react-use-rect": "1.1.1", - "@radix-ui/react-use-size": "1.1.1", - "@radix-ui/rect": "1.1.1" + "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4408,7 +6134,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4431,7 +6157,7 @@ } } }, - "node_modules/@radix-ui/react-popper/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-toast/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4449,14 +6175,15 @@ } } }, - "node_modules/@radix-ui/react-portal": { - "version": "1.1.9", - "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", - "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", "license": "MIT", "dependencies": { + "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", @@ -4473,7 +6200,36 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.11.tgz", + "integrity": "sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "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 + } + } + }, + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4496,7 +6252,7 @@ } } }, - "node_modules/@radix-ui/react-portal/node_modules/@radix-ui/react-slot": { + "node_modules/@radix-ui/react-toggle-group/node_modules/@radix-ui/react-slot": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", @@ -4514,14 +6270,13 @@ } } }, - "node_modules/@radix-ui/react-presence": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", - "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "license": "MIT", "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2", - "@radix-ui/react-use-layout-effect": "1.1.1" + "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", @@ -4538,44 +6293,37 @@ } } }, - "node_modules/@radix-ui/react-primitive": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.4.tgz", - "integrity": "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg==", + "node_modules/@radix-ui/react-toggle/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { - "@radix-ui/react-slot": "1.2.4" + "@radix-ui/react-compose-refs": "1.1.2" }, "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" + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "peerDependenciesMeta": { "@types/react": { "optional": true - }, - "@types/react-dom": { - "optional": true } } }, - "node_modules/@radix-ui/react-roving-focus": { + "node_modules/@radix-ui/react-toolbar": { "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.11.tgz", - "integrity": "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toolbar/-/react-toolbar-1.1.11.tgz", + "integrity": "sha512-4ol06/1bLoFu1nwUqzdD4Y5RZ9oDdKeiHIsntug54Hcr1pgaHiPqHFEaXI1IFP/EsOfROQZ8Mig9VTIRza6Tjg==", "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", - "@radix-ui/react-collection": "1.1.7", - "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", - "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", - "@radix-ui/react-use-callback-ref": "1.1.1", - "@radix-ui/react-use-controllable-state": "1.2.2" + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-toggle-group": "1.1.11" }, "peerDependencies": { "@types/react": "*", @@ -4592,7 +6340,7 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": { + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-primitive": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", @@ -4615,31 +6363,13 @@ } } }, - "node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", - "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", - "license": "MIT", - "dependencies": { - "@radix-ui/react-compose-refs": "1.1.2" - }, - "peerDependencies": { - "@types/react": "*", - "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@radix-ui/react-separator": { - "version": "1.1.8", - "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.8.tgz", - "integrity": "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g==", + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", "license": "MIT", "dependencies": { - "@radix-ui/react-primitive": "2.1.4" + "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", @@ -4656,10 +6386,10 @@ } } }, - "node_modules/@radix-ui/react-slot": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", - "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "node_modules/@radix-ui/react-toolbar/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -4819,6 +6549,24 @@ } } }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-use-layout-effect": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", @@ -14638,6 +16386,193 @@ ], "license": "MIT" }, + "node_modules/radix-ui": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/radix-ui/-/radix-ui-1.4.3.tgz", + "integrity": "sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-accessible-icon": "1.1.7", + "@radix-ui/react-accordion": "1.2.12", + "@radix-ui/react-alert-dialog": "1.1.15", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-aspect-ratio": "1.1.7", + "@radix-ui/react-avatar": "1.1.10", + "@radix-ui/react-checkbox": "1.3.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-context-menu": "2.2.16", + "@radix-ui/react-dialog": "1.1.15", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.11", + "@radix-ui/react-dropdown-menu": "2.1.16", + "@radix-ui/react-focus-guards": "1.1.3", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-form": "0.1.8", + "@radix-ui/react-hover-card": "1.1.15", + "@radix-ui/react-label": "2.1.7", + "@radix-ui/react-menu": "2.1.16", + "@radix-ui/react-menubar": "1.1.16", + "@radix-ui/react-navigation-menu": "1.2.14", + "@radix-ui/react-one-time-password-field": "0.1.8", + "@radix-ui/react-password-toggle-field": "0.1.3", + "@radix-ui/react-popover": "1.1.15", + "@radix-ui/react-popper": "1.2.8", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-progress": "1.1.7", + "@radix-ui/react-radio-group": "1.3.8", + "@radix-ui/react-roving-focus": "1.1.11", + "@radix-ui/react-scroll-area": "1.2.10", + "@radix-ui/react-select": "2.2.6", + "@radix-ui/react-separator": "1.1.7", + "@radix-ui/react-slider": "1.3.6", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-switch": "1.2.6", + "@radix-ui/react-tabs": "1.1.13", + "@radix-ui/react-toast": "1.2.15", + "@radix-ui/react-toggle": "1.1.10", + "@radix-ui/react-toggle-group": "1.1.11", + "@radix-ui/react-toolbar": "1.1.11", + "@radix-ui/react-tooltip": "1.2.8", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-escape-keydown": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "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 + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "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 + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "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 + } + } + }, + "node_modules/radix-ui/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/raw-body": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.4.1.tgz", diff --git a/package.json b/package.json index d8f4c20..618816b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "next-themes": "^0.4.6", "nodemailer": "^7.0.10", "qrcode": "^1.5.4", + "radix-ui": "^1.4.3", "react": "18.3.1", "react-dom": "18.3.1", "react-downloadfile-hook": "^1.0.3", diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts new file mode 100644 index 0000000..24ebfa9 --- /dev/null +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/refresh/route.ts @@ -0,0 +1,44 @@ +import { auth } from "@/src/lib/auth"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + databaseId: string; + }>; +} + +export async function POST(_: Request, context: Context) { + const { teamId, databaseId } = await context.params; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!databaseId) { + return NextResponse.json( + { error: "Database ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const refreshedDb = await ExternalDatabaseService.RefreshDatabase( + session.user.id, + teamId, + databaseId, + ); + return NextResponse.json(refreshedDb); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts index c14f667..61c4a34 100644 --- a/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts +++ b/src/app/api/teams/[teamId]/databases/[databaseId]/route.ts @@ -1,6 +1,7 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; +import { DatabaseRotateSchema } from "@/src/lib/types/database-types"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; import z, { ZodError } from "zod"; @@ -12,7 +13,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const databaseId = params.databaseId; @@ -38,12 +39,12 @@ export async function DELETE(req: Request, context: Context) { } try { - await ExternalDatabaseService.DeleteDatabase( + const deletedDb = await ExternalDatabaseService.DeleteDatabase( session.user.id, teamId, databaseId, ); - return NextResponse.json({ message: "Database deleted successfully" }); + return NextResponse.json(deletedDb); } catch (error) { return ErrorToNextResponse(error); } @@ -104,3 +105,58 @@ export async function PATCH(req: Request, context: Context) { return ErrorToNextResponse(error); } } + +export async function POST(req: Request, context: Context) { + const params = await context.params; + const teamId = params.teamId; + const databaseId = params.databaseId; + + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!databaseId) { + return NextResponse.json( + { error: "Database ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = DatabaseRotateSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const rotatedDb = await ExternalDatabaseService.RotateDatabaseCredentials( + session.user.id, + teamId, + databaseId, + { + AccessKeyID: validatedData.accessKey, + SecretAccessKey: validatedData.secretKey, + }, + ); + return NextResponse.json(rotatedDb); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/databases/route.ts b/src/app/api/teams/[teamId]/databases/route.ts index 0a78382..a55b46a 100644 --- a/src/app/api/teams/[teamId]/databases/route.ts +++ b/src/app/api/teams/[teamId]/databases/route.ts @@ -1,5 +1,6 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { isSafeEndpointUrl } from "@/src/lib/utils/url-utils"; import { ExternalDatabaseService } from "@/src/services/ExternalDatabaseService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -11,7 +12,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; @@ -41,7 +42,10 @@ export async function GET(req: Request, context: Context) { const PostSchema = z.object({ type: z.enum(["S3"]).default("S3"), name: z.string().min(1).max(64), - endpoint: z.url("Invalid URL").max(2048, "Max URL length exceeded"), + endpoint: z + .url("Invalid URL") + .max(2048, "Max URL length exceeded") + .refine(isSafeEndpointUrl, "Endpoint URL must be a public HTTPS address"), region: z.string().min(1).max(50), bucketName: z.string().min(1).max(100), accessKey: z.string().min(1).max(256), diff --git a/src/app/api/teams/[teamId]/members/[memberId]/route.ts b/src/app/api/teams/[teamId]/members/[memberId]/route.ts index a553776..b218dec 100644 --- a/src/app/api/teams/[teamId]/members/[memberId]/route.ts +++ b/src/app/api/teams/[teamId]/members/[memberId]/route.ts @@ -1,6 +1,6 @@ import { team_member } from "@/src/db/schema"; import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -13,7 +13,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; const memberId = params.memberId; diff --git a/src/app/api/teams/[teamId]/members/route.ts b/src/app/api/teams/[teamId]/members/route.ts index ccd5942..78188b8 100644 --- a/src/app/api/teams/[teamId]/members/route.ts +++ b/src/app/api/teams/[teamId]/members/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -10,7 +10,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts new file mode 100644 index 0000000..24fa55c --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/refresh/route.ts @@ -0,0 +1,43 @@ +import { auth } from "@/src/lib/auth"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + credentialId: string; + }>; +} + +export async function POST(_: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const newCred = await RobloxCredentialsService.RefreshRobloxCredential( + session.user.id, + teamId, + credentialId, + ); + return NextResponse.json(newCred); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts new file mode 100644 index 0000000..1033273 --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/[credentialId]/route.ts @@ -0,0 +1,147 @@ +import { auth } from "@/src/lib/auth"; +import { + RobloxCredentialRenameSchema, + RobloxCredentialRotateSchema, +} from "@/src/lib/types/roblox-credentials-types"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + credentialId: string; + }>; +} + +export async function DELETE(_: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const deletedCredential = + await RobloxCredentialsService.DeleteRobloxCredential( + session.user.id, + teamId, + credentialId, + ); + return NextResponse.json(deletedCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function PATCH(req: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialRenameSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const renamedCredential = + await RobloxCredentialsService.RenameRobloxCredential( + session.user.id, + teamId, + credentialId, + validatedData.name, + ); + return NextResponse.json(renamedCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function POST(req: Request, context: Context) { + const { teamId, credentialId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + if (!credentialId) { + return NextResponse.json( + { error: "Credential ID is required" }, + { status: 400 }, + ); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialRotateSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const rotatedCred = await RobloxCredentialsService.RotateRobloxCredential( + session.user.id, + teamId, + credentialId, + validatedData.key, + ); + return NextResponse.json(rotatedCred); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/roblox-credentials/route.ts b/src/app/api/teams/[teamId]/roblox-credentials/route.ts new file mode 100644 index 0000000..f19d054 --- /dev/null +++ b/src/app/api/teams/[teamId]/roblox-credentials/route.ts @@ -0,0 +1,79 @@ +import { auth } from "@/src/lib/auth"; +import { RobloxCredentialInfoSchema } from "@/src/lib/types/roblox-credentials-types"; +import { AccessDenied, ErrorToNextResponse } from "@/src/lib/utils/api-utils"; +import { RobloxCredentialsService } from "@/src/services/RobloxCredentialsService"; +import { headers } from "next/headers"; +import { NextResponse } from "next/server"; + +interface Context { + params: Promise<{ + teamId: string; + }>; +} + +export async function GET(_: Request, context: Context) { + const { teamId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + try { + const credentials = + await RobloxCredentialsService.ListTeamRobloxCredentials( + session.user.id, + teamId, + ); + return NextResponse.json(credentials); + } catch (error) { + return ErrorToNextResponse(error); + } +} + +export async function POST(req: Request, context: Context) { + const { teamId } = await context.params; + if (!teamId) { + return NextResponse.json({ error: "Team ID is required" }, { status: 400 }); + } + + const session = await auth.api.getSession({ + headers: await headers(), + }); + if (!session) { + return ErrorToNextResponse(AccessDenied); + } + + let body; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid json body" }, { status: 400 }); + } + + let validatedData; + try { + validatedData = RobloxCredentialInfoSchema.parse(body); + } catch { + return NextResponse.json( + { error: "Invalid body request format" }, + { status: 400 }, + ); + } + + try { + const newCredential = await RobloxCredentialsService.LinkRobloxCredential( + session.user.id, + teamId, + validatedData, + ); + return NextResponse.json(newCredential); + } catch (error) { + return ErrorToNextResponse(error); + } +} diff --git a/src/app/api/teams/[teamId]/route.ts b/src/app/api/teams/[teamId]/route.ts index a52c210..d13f35e 100644 --- a/src/app/api/teams/[teamId]/route.ts +++ b/src/app/api/teams/[teamId]/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -11,7 +11,7 @@ interface Context { }>; } -export async function DELETE(req: Request, context: Context) { +export async function DELETE(_: Request, context: Context) { const params = await context.params; const teamId = params.teamId; diff --git a/src/app/api/teams/[teamId]/transfer-ownership/route.ts b/src/app/api/teams/[teamId]/transfer-ownership/route.ts index f4d3c66..4d124fc 100644 --- a/src/app/api/teams/[teamId]/transfer-ownership/route.ts +++ b/src/app/api/teams/[teamId]/transfer-ownership/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/api/teams/resolve-slug/[slug]/route.ts b/src/app/api/teams/resolve-slug/[slug]/route.ts index 47743bc..c9f38e0 100644 --- a/src/app/api/teams/resolve-slug/[slug]/route.ts +++ b/src/app/api/teams/resolve-slug/[slug]/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; @@ -10,7 +10,7 @@ interface Context { }>; } -export async function GET(req: Request, context: Context) { +export async function GET(_: Request, context: Context) { const params = await context.params; const slug = params.slug; diff --git a/src/app/api/teams/route.ts b/src/app/api/teams/route.ts index 7f0abd2..38f84a5 100644 --- a/src/app/api/teams/route.ts +++ b/src/app/api/teams/route.ts @@ -1,5 +1,5 @@ import { auth } from "@/src/lib/auth"; -import { ErrorToNextResponse } from "@/src/lib/utils/errors"; +import { ErrorToNextResponse } from "@/src/lib/utils/api-utils"; import { TeamService } from "@/src/services/TeamService"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; diff --git a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx index 12c70e3..2f923b2 100644 --- a/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx +++ b/src/app/dashboard/[teamSlug]/components/TeamSidebar.tsx @@ -1,10 +1,8 @@ "use client"; -import NavigationSidebar, { - ItemGroup, -} from "@/src/components/NavigationSidebar"; +import NavigationSidebar, { ItemGroup } from "@/src/components/NavigationSidebar"; import { useTeam } from "@/src/hooks/useTeam"; import { hasPermission } from "@/src/lib/utils/team-utils"; -import { Box, Database, Settings, User } from "lucide-react"; +import { Box, Cable, Settings, User } from "lucide-react"; export default function TeamSidebar() { const { data, isLoading } = useTeam(); @@ -34,13 +32,16 @@ export default function TeamSidebar() { url: "/settings", }, { - title: "Databases", - Icon: Database, - url: "/databases", + title: "Connections", + Icon: Cable, + url: "/connections", }, ].filter((item) => { - if (item.title === "Databases") { - return hasPermission(data?.role, "ListDatabases"); + if (item.title === "Connections") { + return ( + hasPermission(data?.role, "ListDatabases") || + hasPermission(data?.role, "ListRobloxCredentials") + ); } return true; }), diff --git a/src/app/dashboard/[teamSlug]/databases/DatabaseColumn.tsx b/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx similarity index 51% rename from src/app/dashboard/[teamSlug]/databases/DatabaseColumn.tsx rename to src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx index 248d252..0ea973d 100644 --- a/src/app/dashboard/[teamSlug]/databases/DatabaseColumn.tsx +++ b/src/app/dashboard/[teamSlug]/connections/DatabaseColumn.tsx @@ -1,5 +1,7 @@ import CallbackDialog from "@/src/components/CallbackDialog"; import FormDialog from "@/src/components/FormDialog"; +import LocalTime from "@/src/components/LocalTime"; +import StatusBadge from "@/src/components/StatusBadge"; import { Button } from "@/src/components/ui/button"; import { DropdownMenu, @@ -8,22 +10,16 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; -import { - FormControl, - FormField, - FormItem, - FormLabel, - FormMessage, -} from "@/src/components/ui/form"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useDatabaseMutations } from "@/src/hooks/useDatabase"; import { useTeam } from "@/src/hooks/useTeam"; -import { Database } from "@/src/lib/types/database-types"; +import { Database, DatabaseRotateSchema } from "@/src/lib/types/database-types"; import { hasPermission } from "@/src/lib/utils/team-utils"; import { zodResolver } from "@hookform/resolvers/zod"; import { ColumnDef } from "@tanstack/react-table"; -import { MoreHorizontal } from "lucide-react"; +import { MoreHorizontal, Copy, RefreshCw, RotateCcwKey, Pencil, Trash } from "lucide-react"; import { useState } from "react"; import { useForm } from "react-hook-form"; import { toast } from "sonner"; @@ -91,11 +87,83 @@ const DatabaseRenameDialog = ({ Name - + + + + + )} + /> + + ); +}; + +const RotateDatabaseDialog = ({ + databaseId, + teamId, + trigger, +}: { + databaseId: string; + teamId: string; + trigger: React.ReactNode; +}) => { + const [open, onOpenChange] = useState(false); + const { rotateDatabase } = useDatabaseMutations(); + const form = useForm({ + resolver: zodResolver(DatabaseRotateSchema), + defaultValues: { + accessKey: "", + secretKey: "", + }, + }); + + return ( + { + const id = toast.loading("Rotating database credentials..."); + try { + await rotateDatabase.mutateAsync({ teamId, databaseId, accessKey, secretKey }); + toast.success("Successfully rotated database credentials!", { id }); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while rotating your database credentials. Please try again later.", + { id }, + ); + } + } + onOpenChange(false); + }} + trigger={trigger} + submitButtonText="Rotate" + open={open} + onOpenChange={onOpenChange} + > + ( + + Access Key ID + + + + + + )} + /> + ( + + Secret Access Key + + @@ -110,6 +178,30 @@ export const databaseColumn: ColumnDef[] = [ accessorKey: "name", header: "Name", }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + + if (!team || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + ); + }, + }, { accessorKey: "endpoint", header: "Endpoint URI", @@ -131,12 +223,28 @@ export const databaseColumn: ColumnDef[] = [ accessorKey: "type", header: "Type", }, + { + accessorKey: "lastUsed", + header: "Last Used", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, { id: "actions", cell: ({ row }) => { const db = row.original; const { data: team, isLoading } = useTeam(); - const { deleteDatabase } = useDatabaseMutations(); + const { deleteDatabase, refreshDatabase } = useDatabaseMutations(); if (!team || isLoading) { return ( @@ -146,6 +254,25 @@ export const databaseColumn: ColumnDef[] = [ ); } + const handleRefresh = () => { + const id = toast.loading("Refreshing database status..."); + refreshDatabase + .mutateAsync({ teamId: team.id, databaseId: db.id }) + .then(() => { + toast.success("Successfully refreshed database status!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while refreshing your database. Please try again later.", + { id }, + ); + } + }); + }; + return (
@@ -156,22 +283,41 @@ export const databaseColumn: ColumnDef[] = [ + navigator.clipboard.writeText(db.id)}> + + Copy Database ID + navigator.clipboard.writeText(db.id)} + disabled={!hasPermission(team.role, "RefreshDatabase")} + onClick={handleRefresh} > - Copy Database ID + + Refresh Status + { - e.preventDefault(); - }} + onSelect={(e) => e.preventDefault()} + > + + Rename Database + + } + /> + e.preventDefault()} > - Rename Database + + Rotate Credentials } /> @@ -203,11 +349,10 @@ export const databaseColumn: ColumnDef[] = [ { - e.preventDefault(); - }} + onSelect={(e) => e.preventDefault()} > - Delete Database + + Delete Database } /> diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/DatabaseTypeSelectorDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/DatabaseTypeSelectorDialog.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/DatabaseTypeSelectorDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/DatabaseTypeSelectorDialog.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/LinkDatabaseDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/LinkDatabaseDialog.tsx similarity index 100% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/LinkDatabaseDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/LinkDatabaseDialog.tsx diff --git a/src/app/dashboard/[teamSlug]/databases/LinkDialog/S3DatabaseDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx similarity index 97% rename from src/app/dashboard/[teamSlug]/databases/LinkDialog/S3DatabaseDialog.tsx rename to src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx index 76bbf68..16dc0a7 100644 --- a/src/app/dashboard/[teamSlug]/databases/LinkDialog/S3DatabaseDialog.tsx +++ b/src/app/dashboard/[teamSlug]/connections/LinkDialog/S3DatabaseDialog.tsx @@ -1,5 +1,4 @@ import FormDialog from "@/src/components/FormDialog"; -import { queryClient } from "@/src/components/QueryClientWrapper"; import { FormControl, FormField, @@ -8,7 +7,6 @@ import { FormMessage, } from "@/src/components/ui/form"; import { Input } from "@/src/components/ui/input"; -import { LinkDatabase } from "@/src/controllers/ExternalDatabaseController"; import { useDatabaseMutations } from "@/src/hooks/useDatabase"; import { useTeam } from "@/src/hooks/useTeam"; import { zodResolver } from "@hookform/resolvers/zod"; diff --git a/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx new file mode 100644 index 0000000..a6a0b1c --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/LinkRoCredDialog.tsx @@ -0,0 +1,101 @@ +import FormDialog from "@/src/components/FormDialog"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/src/components/ui/form"; +import { Input } from "@/src/components/ui/input"; +import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; +import { useTeam } from "@/src/hooks/useTeam"; +import { RobloxCredentialInfoSchema } from "@/src/lib/types/roblox-credentials-types"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +interface LinkRobloxCredentialDialogProps { + open: boolean; + setIsOpen: (open: boolean) => void; +} +export default function LinkRobloxCredentialDialog({ + open, + setIsOpen, +}: LinkRobloxCredentialDialogProps) { + const { linkRobloxCredential } = useRobloxCredentialMutations(); + const { data: team } = useTeam(); + + const form = useForm({ + resolver: zodResolver(RobloxCredentialInfoSchema), + defaultValues: { + name: "", + key: "", + }, + }); + + return ( + { + const id = toast.loading("Linking Roblox API key to your team..."); + linkRobloxCredential + .mutateAsync({ + teamId: team!.id, + data: { name, key }, + }) + .then(() => { + toast.success("Successfully linked Roblox API key to your team!", { + id, + }); + setIsOpen(false); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while linking Roblox API key to your team. Please try again later.", + { id }, + ); + } + }); + }} + > + ( + + Name + + + + + + )} + /> + ( + + API Key + + + + + + )} + /> + + ); +} diff --git a/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx new file mode 100644 index 0000000..7a2b0db --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/RobloxCredentialColumn.tsx @@ -0,0 +1,339 @@ +import CallbackDialog from "@/src/components/CallbackDialog"; +import FormDialog from "@/src/components/FormDialog"; +import LocalTime from "@/src/components/LocalTime"; +import StatusBadge from "@/src/components/StatusBadge"; +import { Button } from "@/src/components/ui/button"; +import { + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/src/components/ui/dropdown-menu"; +import { FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/src/components/ui/form"; +import { Input } from "@/src/components/ui/input"; +import { Skeleton } from "@/src/components/ui/skeleton"; +import { useRobloxCredentialMutations } from "@/src/hooks/useRobloxCredential"; +import { useTeam } from "@/src/hooks/useTeam"; +import { + RobloxCredential, + RobloxCredentialRenameSchema, + RobloxCredentialRotateSchema, +} from "@/src/lib/types/roblox-credentials-types"; +import { hasPermission } from "@/src/lib/utils/team-utils"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { DropdownMenu } from "@radix-ui/react-dropdown-menu"; +import { ColumnDef } from "@tanstack/react-table"; +import { Copy, MoreHorizontal, Pencil, RefreshCw, RotateCcwKey, Trash } from "lucide-react"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +const RenameRobloxCredentialDialog = ({ + credId, + children, +}: { credId: string } & React.PropsWithChildren) => { + const { data: team } = useTeam(); + const { renameRobloxCredential } = useRobloxCredentialMutations(); + const form = useForm({ + resolver: zodResolver(RobloxCredentialRenameSchema), + defaultValues: { + name: "", + }, + }); + + return ( + { + const id = toast.loading("Renaming Roblox credential..."); + renameRobloxCredential + .mutateAsync({ teamId: team!.id, credId, newName: name }) + .then(() => { + toast.success("Successfully renamed Roblox credential!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while renaming Roblox credential. Please try again later.", + { id }, + ); + } + }); + }} + submitButtonText="Rename" + trigger={children} + > + ( + + Name + + + + + + )} + /> + + ); +}; + +const RotateRobloxCredentialDialog = ({ + credId, + children, +}: { credId: string } & React.PropsWithChildren) => { + const { data: team } = useTeam(); + const { rotateRobloxCredential } = useRobloxCredentialMutations(); + const form = useForm({ + resolver: zodResolver(RobloxCredentialRotateSchema), + defaultValues: { + key: "", + }, + }); + + return ( + { + const id = toast.loading("Rotating Roblox credential..."); + rotateRobloxCredential + .mutateAsync({ teamId: team!.id, credId, newKey: key }) + .then(() => { + toast.success("Successfully rotated Roblox credential!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while rotating Roblox credential. Please try again later.", + { id }, + ); + } + }); + }} + submitButtonText="Rotate" + trigger={children} + > + ( + + Key + + + + + + )} + /> + + ); +}; + +export const robloxCredentialColumn: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + }, + { + accessorKey: "keyOwnerRobloxId", + header: "Roblox User", + cell: ({ getValue }) => { + const userId = getValue(); + return ( + + View Profile + + ); + }, + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + + if (!team || isLoading) { + return ( +
+ +
+ ); + } + + return ( + + ); + }, + }, + { + accessorKey: "expirationDate", + header: "Expires In", + cell: ({ getValue }) => { + const time = getValue(); + if (!!time) { + return ; + } else { + return "Never"; + } + }, + }, + { + accessorKey: "lastUsed", + header: "Last Used", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + accessorKey: "createdAt", + header: "Created", + cell: ({ getValue }) => { + const time = new Date(getValue()); + return ; + }, + }, + { + id: "actions", + cell: ({ row }) => { + const cred = row.original; + const { data: team, isLoading } = useTeam(); + const { deleteRobloxCredential, refreshRobloxCredential } = useRobloxCredentialMutations(); + + if (!team || isLoading) { + return ( +
+ +
+ ); + } + + const handleKeyRefresh = () => { + const id = toast.loading("Refreshing Roblox credential info..."); + refreshRobloxCredential + .mutateAsync({ teamId: team.id, credId: cred.id }) + .then(() => { + toast.success("Successfully refreshed Roblox credential info!", { id }); + }) + .catch((error) => { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error happened while refreshing Roblox credential. Please try again later.", + { id }, + ); + } + }); + }; + + return ( +
+ + + + + + navigator.clipboard.writeText(cred.id)}> + + Copy Key ID + + handleKeyRefresh()} + > + + Refresh Key + + + + { + e.preventDefault(); + }} + > + + Rename Key + + + + { + e.preventDefault(); + }} + > + + Rotate Key + + + + { + const id = toast.loading("Deleting roblox credential..."); + try { + await deleteRobloxCredential.mutateAsync({ + teamId: team.id, + credId: cred.id, + }); + toast.success("Successfully deleted roblox credential!", { + id, + }); + } catch (error) { + if (error instanceof Error) { + toast.error(error.message, { id }); + } else { + toast.error( + "An unknown error occured while deleting your roblox credential. Please try again later.", + { id }, + ); + } + } + }} + confirmationText={cred.name} + trigger={ + { + e.preventDefault(); + }} + > + + Delete Key + + } + /> + + +
+ ); + }, + }, +]; diff --git a/src/app/dashboard/[teamSlug]/connections/page.tsx b/src/app/dashboard/[teamSlug]/connections/page.tsx new file mode 100644 index 0000000..39f3722 --- /dev/null +++ b/src/app/dashboard/[teamSlug]/connections/page.tsx @@ -0,0 +1,93 @@ +"use client"; +import { DataTable } from "@/src/components/DataTable"; +import { useDatabases } from "@/src/hooks/useDatabase"; +import { databaseColumn } from "./DatabaseColumn"; +import { Button } from "@/src/components/ui/button"; +import { Plus } from "lucide-react"; +import { useEffect, useState } from "react"; +import LinkDatabaseDialog from "./LinkDialog/LinkDatabaseDialog"; +import { hasPermission } from "@/src/lib/utils/team-utils"; +import { useTeam } from "@/src/hooks/useTeam"; +import { robloxCredentialColumn } from "./RobloxCredentialColumn"; +import LinkRobloxCredentialDialog from "./LinkRoCredDialog"; +import { useRobloxCredentials } from "@/src/hooks/useRobloxCredential"; +import { useRouter } from "next/navigation"; + +export default function Page() { + const [isLinkDbOpen, setIsLinkDbOpen] = useState(false); + const [isLinkCredOpen, setIsLinkCredOpen] = useState(false); + const router = useRouter(); + const { data: team } = useTeam(); + const { data: dbData, isLoading: isDbLoading } = useDatabases(); + const { data: credData, isLoading: isCredLoading } = useRobloxCredentials(); + + useEffect(() => { + if (!team) return; + if ( + !hasPermission(team.role, "ListDatabases") && + !hasPermission(team.role, "ListRobloxCredentials") + ) { + router.replace(`/dashboard/${team.slug}`); + } + }, [team]); + + return ( + <> + Connections + {hasPermission(team?.role, "ListDatabases") && ( + <> +
+ Databases + setIsLinkDbOpen(true)} + disabled={!hasPermission(team?.role, "LinkDatabase")} + > + + Link Database + + } + /> +
+ + + )} + {hasPermission(team?.role, "ListRobloxCredentials") && ( + <> +
+ Roblox Credentials + setIsLinkCredOpen(true)} + disabled={!hasPermission(team?.role, "LinkRobloxCredential")} + > + + Link Roblox Credential + + } + /> +
+ + + )} + + ); +} diff --git a/src/app/dashboard/[teamSlug]/databases/page.tsx b/src/app/dashboard/[teamSlug]/databases/page.tsx deleted file mode 100644 index 968edfd..0000000 --- a/src/app/dashboard/[teamSlug]/databases/page.tsx +++ /dev/null @@ -1,42 +0,0 @@ -"use client"; -import { DataTable } from "@/src/components/DataTable"; -import { useDatabases } from "@/src/hooks/useDatabase"; -import { databaseColumn } from "./DatabaseColumn"; -import { Button } from "@/src/components/ui/button"; -import { Plus } from "lucide-react"; -import { useState } from "react"; -import LinkDatabaseDialog from "./LinkDialog/LinkDatabaseDialog"; -import { hasPermission } from "@/src/lib/utils/team-utils"; -import { useTeam } from "@/src/hooks/useTeam"; - -export default function Page() { - const [open, setIsOpen] = useState(false); - const { data: team } = useTeam(); - const { data, isLoading } = useDatabases(); - - return ( - <> - Databases - setIsOpen(true)} - disabled={!hasPermission(team?.role, "LinkDatabase")} - > - - Link Database - - } - /> - - - ); -} diff --git a/src/app/dashboard/components/TeamColumn.tsx b/src/app/dashboard/components/TeamColumn.tsx index 650abf1..097aabf 100644 --- a/src/app/dashboard/components/TeamColumn.tsx +++ b/src/app/dashboard/components/TeamColumn.tsx @@ -7,15 +7,16 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/src/components/ui/dropdown-menu"; import { Skeleton } from "@/src/components/ui/skeleton"; -import { RemoveTeamMember } from "@/src/controllers/TeamController"; +import { TeamController } from "@/src/controllers/TeamController"; import { authClient } from "@/src/lib/auth-client"; import { Team } from "@/src/lib/types/team-types"; import { useMutation } from "@tanstack/react-query"; import { ColumnDef } from "@tanstack/react-table"; -import { MoreHorizontal } from "lucide-react"; +import { Copy, LogOut, MoreHorizontal } from "lucide-react"; import { toast } from "sonner"; export const teamColumns: ColumnDef[] = [ @@ -54,7 +55,13 @@ export const teamColumns: ColumnDef[] = [ cell: ({ row }) => { const { data, isPending } = authClient.useSession(); const { mutateAsync: leaveTeam } = useMutation({ - mutationFn: RemoveTeamMember, + mutationFn: ({ + teamId, + memberId, + }: { + teamId: string; + memberId: string; + }) => TeamController.removeMember(teamId, memberId), onSuccess: (_, { teamId }) => { queryClient.setQueryData(["teams"], (prevData) => prevData ? prevData.filter((team) => team.id !== teamId) : [], @@ -81,8 +88,10 @@ export const teamColumns: ColumnDef[] = [ navigator.clipboard.writeText(team.id)} > - Copy Team ID + + Copy Team ID + [] = [ variant="destructive" onSelect={(e) => e.preventDefault()} > - Leave Team + + Leave Team } /> diff --git a/src/components/FormDialog.tsx b/src/components/FormDialog.tsx index dc33752..5fa7ed4 100644 --- a/src/components/FormDialog.tsx +++ b/src/components/FormDialog.tsx @@ -10,7 +10,7 @@ import { DialogTrigger, } from "@/src/components/ui/dialog"; import { Form } from "@/src/components/ui/form"; -import React from "react"; +import React, { useState } from "react"; import { FieldValues, UseFormReturn } from "react-hook-form"; export interface FormDialogProps { @@ -53,15 +53,20 @@ export default function FormDialog({ trigger, form, callback, - open, - onOpenChange, + open: externalOpen, + onOpenChange: setExternalOpen, children, }: FormDialogProps & React.PropsWithChildren) { const [isLoading, setIsLoading] = React.useState(false); + const [internalOpen, setInternalOpen] = useState(false); + + const open = externalOpen ?? internalOpen; + const onOpenChange = setExternalOpen ?? setInternalOpen; const handleSubmit = async (data: TFormData) => { setIsLoading(true); await callback(data); + onOpenChange(false); form.reset(); setIsLoading(false); }; diff --git a/src/components/LocalTime.tsx b/src/components/LocalTime.tsx index 2ff9527..c31edf7 100644 --- a/src/components/LocalTime.tsx +++ b/src/components/LocalTime.tsx @@ -1,23 +1,38 @@ "use client"; import { useEffect, useState } from "react"; -const formatter = Intl.DateTimeFormat(undefined, { +const formatter = Intl.DateTimeFormat("en-US", { day: "2-digit", month: "short", year: "numeric", - hour: "2-digit", - minute: "2-digit", }); -export default function LocalTime({ time }: { time: Date }) { - const [isMounted, setIsMounted] = useState(false); +const relativeFormatter = new Intl.RelativeTimeFormat("en-US", { numeric: "auto" }); - useEffect(() => { - setIsMounted(true); - }, []); +const units: { unit: Intl.RelativeTimeFormatUnit; amount: number }[] = [ + { unit: "year", amount: 365 * 24 * 60 * 60 }, + { unit: "month", amount: 30 * 24 * 60 * 60 }, + { unit: "day", amount: 24 * 60 * 60 }, + { unit: "hour", amount: 60 * 60 }, + { unit: "minute", amount: 60 }, + { unit: "second", amount: 1 }, +]; - if (!isMounted) { - return Loading...; +export default function LocalTime({ + time, + mode = "relative", +}: { + time: Date; + mode?: "absolute" | "relative"; +}) { + if (mode === "relative") { + const diffInSec = Math.floor((time.getTime() - Date.now()) / 1000); + for (const { unit, amount } of units) { + if (Math.abs(diffInSec) >= amount) { + const timeString = relativeFormatter.format(Math.floor(diffInSec / amount), unit); + return {timeString.charAt(0).toUpperCase() + timeString.slice(1)}; + } + } } return {formatter.format(time)}; diff --git a/src/components/StatusBadge.tsx b/src/components/StatusBadge.tsx new file mode 100644 index 0000000..487b255 --- /dev/null +++ b/src/components/StatusBadge.tsx @@ -0,0 +1,98 @@ +import React from "react"; +import { RobloxCredentialStatus } from "../lib/types/roblox-credentials-types"; +import { Badge } from "./ui/badge"; +import { Info } from "lucide-react"; +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverHeader, + PopoverTitle, + PopoverTrigger, +} from "./ui/popover"; +import LocalTime from "./LocalTime"; + +type StatusConfigType = { + label: string; + className: string; + Icon?: React.ReactNode; + popoverTitle: string; + popoverDescription?: string; +}; + +interface StatusBadgeProps { + kind: RobloxCredentialStatus; + errorMessage?: string; + lastRefreshed: Date; +} +export default function StatusBadge({ kind, errorMessage, lastRefreshed }: StatusBadgeProps) { + const statusConfig: Record = { + healthy: { + label: "Healthy", + className: `bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300 cursor-pointer`, + popoverTitle: "System Operational", + }, + warning: { + label: "Warning", + className: `bg-yellow-700 text-orange-200 cursor-pointer`, + Icon: , + popoverTitle: "Attention Required", + popoverDescription: + errorMessage ?? "A non-critical issue was detected. No further details available.", + }, + error: { + label: "Error", + className: `bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300 cursor-pointer`, + Icon: , + popoverTitle: "Action Required", + popoverDescription: + errorMessage ?? + "A critical connection issue occured. Please check your key in the Roblox Creator Dashboard", + }, + }; + + const config = statusConfig[kind] ?? statusConfig.healthy; + const BadgeElement = React.forwardRef>( + ({ className, onClick, ...props }, ref) => { + return ( +
{ + onClick?.(e); + }} + > + + {config.label} + {config.Icon} + +
+ ); + }, + ); + + return ( + + + + + + + + {config.popoverTitle} + + {config.popoverDescription && ( + {config.popoverDescription} + )} + {lastRefreshed && ( + + Last checked: + + + )} + + + + ); +} diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..aae8f8b --- /dev/null +++ b/src/components/ui/badge.tsx @@ -0,0 +1,48 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" +import { Slot } from "radix-ui" + +import { cn } from "@/src/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center justify-center rounded-full border border-transparent px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90", + secondary: + "bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", + destructive: + "bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground", + link: "text-primary underline-offset-4 [a&]: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/src/components/ui/popover.tsx b/src/components/ui/popover.tsx new file mode 100644 index 0000000..c9cb4a0 --- /dev/null +++ b/src/components/ui/popover.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/src/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} diff --git a/src/controllers/ExternalDatabaseController.ts b/src/controllers/ExternalDatabaseController.ts index 1e36eed..6aa54ac 100755 --- a/src/controllers/ExternalDatabaseController.ts +++ b/src/controllers/ExternalDatabaseController.ts @@ -1,67 +1,41 @@ import { Database, DatabaseInfo } from "../lib/types/database-types"; -import { ResponseToError } from "../lib/utils/errors"; - -export async function LinkDatabase( - teamId: string, - data: DatabaseInfo, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases`, { - method: "POST", - body: JSON.stringify(data), - }); - - const responseData = await response.json(); - if (!response.ok) { - console.log(responseData); - throw ResponseToError(responseData); - } - - return responseData as Database; -} - -export async function ListDatabases(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/databases`, { - method: "GET", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database[]; -} - -export async function DeleteDatabase( - teamId: string, - databaseId: string, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, { - method: "DELETE", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database; -} - -export async function RenameDatabase( - teamId: string, - databaseId: string, - newName: string, -): Promise { - const response = await fetch(`/api/teams/${teamId}/databases/${databaseId}`, { - method: "PATCH", - body: JSON.stringify({ name: newName }), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Database; -} +import { fetcher } from "../lib/utils/api-utils"; + +export const ExternalDatabaseController = { + link: (teamId: string, data: DatabaseInfo) => + fetcher(`/api/teams/${teamId}/databases`, { + method: "POST", + body: JSON.stringify(data), + }), + + list: (teamId: string) => + fetcher(`/api/teams/${teamId}/databases`), + + delete: (teamId: string, databaseId: string) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "DELETE", + }), + + rename: (teamId: string, databaseId: string, newName: string) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "PATCH", + body: JSON.stringify({ name: newName }), + }), + + rotate: ( + teamId: string, + databaseId: string, + accessKey: string, + secretKey: string, + ) => + fetcher(`/api/teams/${teamId}/databases/${databaseId}`, { + method: "POST", + body: JSON.stringify({ accessKey, secretKey }), + }), + + refresh: (teamId: string, databaseId: string) => + fetcher( + `/api/teams/${teamId}/databases/${databaseId}/refresh`, + { method: "POST" }, + ), +}; diff --git a/src/controllers/RobloxCredentialController.ts b/src/controllers/RobloxCredentialController.ts new file mode 100644 index 0000000..bf012d4 --- /dev/null +++ b/src/controllers/RobloxCredentialController.ts @@ -0,0 +1,43 @@ +import { + RobloxCredential, + RobloxCredentialInfo, +} from "../lib/types/roblox-credentials-types"; +import { fetcher } from "../lib/utils/api-utils"; + +export const RobloxCredentialController = { + link: (teamId: string, data: RobloxCredentialInfo) => + fetcher(`/api/teams/${teamId}/roblox-credentials`, { + method: "POST", + body: JSON.stringify(data), + }), + + delete: (teamId: string, credId: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { method: "DELETE" }, + ), + + rename: (teamId: string, credId: string, newName: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { method: "PATCH", body: JSON.stringify({ name: newName }) }, + ), + + rotate: (teamId: string, credId: string, newKey: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}`, + { + method: "POST", + body: JSON.stringify({ key: newKey }), + }, + ), + + refresh: (teamId: string, credId: string) => + fetcher( + `/api/teams/${teamId}/roblox-credentials/${credId}/refresh`, + { method: "POST" }, + ), + + list: (teamId: string) => + fetcher(`/api/teams/${teamId}/roblox-credentials`), +}; diff --git a/src/controllers/TeamController.ts b/src/controllers/TeamController.ts index 9817370..68a2c09 100644 --- a/src/controllers/TeamController.ts +++ b/src/controllers/TeamController.ts @@ -1,97 +1,34 @@ import { Team, TeamMember, UserTeam } from "../lib/types/team-types"; -import { ResponseToError } from "../lib/utils/errors"; - -export async function CreateTeam(name: string): Promise { - const response = await fetch("/api/teams", { - method: "POST", - body: JSON.stringify({ name }), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as UserTeam; -} - -export async function DeleteTeam(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}`, { method: "DELETE" }); - - const responseData = await response.json(); - if (!response.ok) { - const error = ResponseToError(responseData); - console.log(error); - throw error; - } - - return responseData as Team; -} - -export async function ListTeams(): Promise { - const response = await fetch(`/api/teams`, { method: "GET" }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as UserTeam[]; -} - -export async function RemoveTeamMember({ - memberId, - teamId, -}: { - memberId: string; - teamId: string; -}): Promise { - const response = await fetch(`/api/teams/${teamId}/members/${memberId}`, { - method: "DELETE", - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as TeamMember; -} - -export async function ResolveTeamBySlug(slug: string): Promise { - const response = await fetch(`/api/teams/resolve-slug/${slug}`); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - return responseData as UserTeam; -} - -export async function ChangeTeamName( - teamId: string, - newName: { name?: string; displayName?: string }, -): Promise { - const response = await fetch(`/api/teams/${teamId}`, { - method: "PATCH", - body: JSON.stringify(newName), - }); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as Team; -} - -export async function ListTeamMembers(teamId: string): Promise { - const response = await fetch(`/api/teams/${teamId}/members`); - - const responseData = await response.json(); - if (!response.ok) { - throw ResponseToError(responseData); - } - - return responseData as TeamMember[]; -} +import { fetcher } from "../lib/utils/api-utils"; + +export const TeamController = { + create: (name: string) => + fetcher("/api/teams", { + method: "POST", + body: JSON.stringify({ name }), + }), + + delete: (teamId: string) => + fetcher(`/api/teams/${teamId}`, { method: "DELETE" }), + + list: () => fetcher("/api/teams"), + + resolve: (slug: string) => + fetcher(`/api/teams/resolve-slug/${slug}`), + + changeName: ( + teamId: string, + newName: { name?: string; displayName?: string }, + ) => + fetcher(`/api/teams/${teamId}`, { + method: "PATCH", + body: JSON.stringify(newName), + }), + + removeMember: (teamId: string, memberId: string) => + fetcher(`/api/teams/${teamId}/members/${memberId}`, { + method: "DELETE", + }), + listMembers: (teamId: string) => + fetcher(`/api/teams/${teamId}/members`), +}; diff --git a/src/db/schema/database.ts b/src/db/schema/database.ts index f23ba7f..6d275a2 100755 --- a/src/db/schema/database.ts +++ b/src/db/schema/database.ts @@ -8,6 +8,12 @@ export const database = sqliteTable("database", { teamId: text("team_id") .notNull() .references(() => team.id, { onDelete: "cascade" }), + + status: text("status", { enum: ["healthy", "warning", "error"] }) + .notNull() + .default("healthy"), + errorMessage: text("error_message"), + type: text("type", { enum: ["S3"] }) .notNull() .default("S3"), @@ -24,6 +30,15 @@ export const database = sqliteTable("database", { skIv: text("sk_iv").notNull(), skTag: text("sk_tag").notNull(), + lastUsed: integer("last_used", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + lastRefreshedAt: integer("last_refreshed_at", { + mode: "timestamp_ms", + }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + createdAt: integer("createdAt", { mode: "timestamp_ms" }) .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) .notNull(), diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index ce40bfa..2b31b5f 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -6,3 +6,4 @@ export * from "./two_factor"; export * from "./database"; export * from "./team"; export * from "./team_member"; +export * from "./roblox_credentials"; diff --git a/src/db/schema/roblox_credentials.ts b/src/db/schema/roblox_credentials.ts new file mode 100644 index 0000000..22d9b0c --- /dev/null +++ b/src/db/schema/roblox_credentials.ts @@ -0,0 +1,41 @@ +import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { team } from "./team"; +import { sql } from "drizzle-orm"; +import { user } from "./user"; + +export const roblox_credentials = sqliteTable("roblox_credentials", { + id: text("id").primaryKey(), + teamId: text("team_id") + .notNull() + .references(() => team.id, { onDelete: "cascade" }), + name: text("name").notNull(), + + status: text("status", { enum: ["healthy", "warning", "error"] }) + .notNull() + .default("healthy"), + errorMessage: text("error_message"), + + keyCiphertext: text("key_ciphertext").notNull(), + keyIv: text("key_iv").notNull(), + keyTag: text("key_tag").notNull(), + + keyOwnerRobloxId: integer("key_owner_roblox_id").notNull(), + + expirationDate: integer("expiration_date", { + mode: "timestamp_ms", + }), + lastUsed: integer("last_used", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + lastRefreshedAt: integer("last_refreshed_at", { + mode: "timestamp_ms", + }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + createdBy: text("created_by").references(() => user.id, { + onDelete: "set null", + }), +}); diff --git a/src/hooks/useDatabase.ts b/src/hooks/useDatabase.ts index ad53d53..6cf82b2 100644 --- a/src/hooks/useDatabase.ts +++ b/src/hooks/useDatabase.ts @@ -1,15 +1,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useTeam } from "./useTeam"; -import { - DeleteDatabase, - LinkDatabase, - ListDatabases, - RenameDatabase, -} from "../controllers/ExternalDatabaseController"; import { Database, DatabaseInfo } from "../lib/types/database-types"; import { useEffect } from "react"; import { hasPermission } from "../lib/utils/team-utils"; import { useRouter } from "next/navigation"; +import { ExternalDatabaseController } from "../controllers/ExternalDatabaseController"; export function useDatabases() { const { @@ -21,16 +16,11 @@ export function useDatabases() { const query = useQuery({ queryKey: ["databases", team?.id], - queryFn: () => ListDatabases(team!.id), + queryFn: () => ExternalDatabaseController.list(team!.id), enabled: !!team?.id, + staleTime: 5 * 60 * 1000, }); - useEffect(() => { - if (team && !hasPermission(team.role, "ListDatabases")) { - router.replace(`/dashboard/${team.slug}/`); - } - }, [team]); - const isLoading = isLoadingTeam || query.isLoading; const isError = isErrorTeam || query.isError; @@ -46,7 +36,7 @@ export function useDatabaseMutations() { const createDatabase = useMutation({ mutationFn: ({ teamId, data }: { teamId: string; data: DatabaseInfo }) => - LinkDatabase(teamId, data), + ExternalDatabaseController.link(teamId, data), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], @@ -65,7 +55,7 @@ export function useDatabaseMutations() { }: { teamId: string; databaseId: string; - }) => DeleteDatabase(teamId, databaseId), + }) => ExternalDatabaseController.delete(teamId, databaseId), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], @@ -86,7 +76,49 @@ export function useDatabaseMutations() { teamId: string; databaseId: string; newName: string; - }) => RenameDatabase(teamId, databaseId, newName), + }) => ExternalDatabaseController.rename(teamId, databaseId, newName), + onSuccess: (database, variables) => { + queryClient.setQueryData( + ["databases", variables.teamId], + (prevData) => { + if (!prevData) return [database]; + return prevData.map((db) => (db.id === database.id ? database : db)); + }, + ); + }, + }); + + const refreshDatabase = useMutation({ + mutationFn: ({ + teamId, + databaseId, + }: { + teamId: string; + databaseId: string; + }) => ExternalDatabaseController.refresh(teamId, databaseId), + onSuccess: (database, variables) => { + queryClient.setQueryData( + ["databases", variables.teamId], + (prevData) => { + if (!prevData) return [database]; + return prevData.map((db) => (db.id === database.id ? database : db)); + }, + ); + }, + }); + + const rotateDatabase = useMutation({ + mutationFn: ({ + teamId, + databaseId, + accessKey, + secretKey, + }: { + teamId: string; + databaseId: string; + accessKey: string; + secretKey: string; + }) => ExternalDatabaseController.rotate(teamId, databaseId, accessKey, secretKey), onSuccess: (database, variables) => { queryClient.setQueryData( ["databases", variables.teamId], @@ -102,5 +134,7 @@ export function useDatabaseMutations() { createDatabase, deleteDatabase, renameDatabase, + refreshDatabase, + rotateDatabase, }; } diff --git a/src/hooks/useMember.ts b/src/hooks/useMember.ts index feeab3e..c4d9835 100644 --- a/src/hooks/useMember.ts +++ b/src/hooks/useMember.ts @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; import { useTeam } from "./useTeam"; -import { ListTeamMembers } from "../controllers/TeamController"; +import { TeamController } from "../controllers/TeamController"; export function useMembers() { const { @@ -12,8 +12,9 @@ export function useMembers() { const query = useQuery({ queryKey: ["members", team?.id], - queryFn: () => ListTeamMembers(team!.id), + queryFn: () => TeamController.listMembers(team!.id), enabled: !!team?.id, + staleTime: 5 * 60 * 1000, }); const isLoading = isLoadingTeam || query.isLoading; diff --git a/src/hooks/useRobloxCredential.ts b/src/hooks/useRobloxCredential.ts new file mode 100644 index 0000000..6897ae9 --- /dev/null +++ b/src/hooks/useRobloxCredential.ts @@ -0,0 +1,142 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useTeam } from "./useTeam"; +import { RobloxCredentialController } from "../controllers/RobloxCredentialController"; +import { useEffect } from "react"; +import { hasPermission } from "../lib/utils/team-utils"; +import { useRouter } from "next/navigation"; +import { + RobloxCredential, + RobloxCredentialInfo, +} from "../lib/types/roblox-credentials-types"; + +export function useRobloxCredentials() { + const router = useRouter(); + const { + data: team, + isLoading: isTeamLoading, + isError: isTeamError, + } = useTeam(); + + const query = useQuery({ + queryKey: ["robloxCredentials", team?.id], + queryFn: () => RobloxCredentialController.list(team!.id), + enabled: !!team?.id, + staleTime: 5 * 60 * 1000, + }); + + const isLoading = isTeamLoading || query.isLoading; + const isError = isTeamError || query.isError; + + return { + ...query, + isLoading, + isError, + }; +} + +export function useRobloxCredentialMutations() { + const queryClient = useQueryClient(); + + const linkRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + data, + }: { + teamId: string; + data: RobloxCredentialInfo; + }) => RobloxCredentialController.link(teamId, data), + onSuccess: (newCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [newCred]; + return [...prevData, newCred]; + }, + ); + }, + }); + + const deleteRobloxCredential = useMutation({ + mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) => + RobloxCredentialController.delete(teamId, credId), + onSuccess: (oldCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return prevData; + return prevData.filter((cred) => cred.id !== oldCred.id); + }, + ); + }, + }); + + const renameRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + credId, + newName, + }: { + teamId: string; + credId: string; + newName: string; + }) => RobloxCredentialController.rename(teamId, credId, newName), + onSuccess: (renamedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [renamedCred]; + return prevData.map((cred) => + cred.id == renamedCred.id ? renamedCred : cred, + ); + }, + ); + }, + }); + + const rotateRobloxCredential = useMutation({ + mutationFn: ({ + teamId, + credId, + newKey, + }: { + teamId: string; + credId: string; + newKey: string; + }) => RobloxCredentialController.rotate(teamId, credId, newKey), + onSuccess: (rotatedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [rotatedCred]; + return prevData.map((cred) => + cred.id == rotatedCred.id ? rotatedCred : cred, + ); + }, + ); + }, + }); + + const refreshRobloxCredential = useMutation({ + mutationFn: ({ teamId, credId }: { teamId: string; credId: string }) => + RobloxCredentialController.refresh(teamId, credId), + onSuccess: (refreshedCred, variables) => { + queryClient.setQueryData( + ["robloxCredentials", variables.teamId], + (prevData) => { + if (!prevData) return [refreshedCred]; + return prevData.map((cred) => + cred.id === refreshedCred.id ? refreshedCred : cred, + ); + }, + ); + }, + }); + + return { + linkRobloxCredential, + deleteRobloxCredential, + renameRobloxCredential, + rotateRobloxCredential, + refreshRobloxCredential, + }; +} diff --git a/src/hooks/useTeam.ts b/src/hooks/useTeam.ts index d400bc8..c6796e7 100644 --- a/src/hooks/useTeam.ts +++ b/src/hooks/useTeam.ts @@ -1,15 +1,9 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams, useRouter } from "next/navigation"; -import { - ChangeTeamName, - CreateTeam, - DeleteTeam, - ListTeams, - ResolveTeamBySlug, -} from "../controllers/TeamController"; import { useEffect } from "react"; import { UserTeam } from "../lib/types/team-types"; +import { TeamController } from "../controllers/TeamController"; export function useTeam() { const { teamSlug } = useParams(); @@ -17,8 +11,9 @@ export function useTeam() { const query = useQuery({ queryKey: ["team", teamSlug], - queryFn: () => ResolveTeamBySlug(teamSlug as string), + queryFn: () => TeamController.resolve(teamSlug as string), enabled: !!teamSlug, + staleTime: 5 * 60 * 1000, retry: false, }); @@ -34,7 +29,8 @@ export function useTeam() { export function useTeams() { return useQuery({ queryKey: ["teams"], - queryFn: ListTeams, + queryFn: () => TeamController.list(), + staleTime: 5 * 60 * 1000, }); } @@ -43,7 +39,7 @@ export function useTeamMutations() { const router = useRouter(); const createTeam = useMutation({ - mutationFn: (name: string) => CreateTeam(name), + mutationFn: (name: string) => TeamController.create(name), onSuccess: (team) => { // Add the newly created team to the teams list queryClient.setQueryData(["teams"], (prevData) => { @@ -54,7 +50,7 @@ export function useTeamMutations() { }); const deleteTeam = useMutation({ - mutationFn: (teamId: string) => DeleteTeam(teamId), + mutationFn: (teamId: string) => TeamController.delete(teamId), onSuccess: (oldTeam) => { // Remove old team from the teams list queryClient.setQueryData(["teams"], (prevData) => { @@ -74,7 +70,7 @@ export function useTeamMutations() { }: { teamId: string; payload: { name?: string; displayName?: string }; - }) => ChangeTeamName(teamId, payload), + }) => TeamController.changeName(teamId, payload), onSuccess: (newTeam, variables) => { const cachedTeam = queryClient.getQueryData(["teams"]); const oldTeam = cachedTeam?.find((team) => team.id === variables.teamId); diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..8e1e2d3 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,11 @@ +export const TEAM_LIMITS = { + free: { + maxMembers: 5, + }, +}; + +export const USER_LIMITS = { + free: { + maxOwnedTeams: 3, + }, +}; diff --git a/src/lib/types/database-types.ts b/src/lib/types/database-types.ts index 1d76943..8e5d890 100644 --- a/src/lib/types/database-types.ts +++ b/src/lib/types/database-types.ts @@ -1,18 +1,42 @@ import { database } from "@/src/db/schema"; import { InferDrizzleSelect } from "../utils"; +import z from "zod"; + +export type DatabaseStatus = typeof database.$inferSelect.status; + +export const database_status = { + connection_failed: "Could not connect to the S3 bucket", + invalid_credentials: "Invalid credentials or insufficient bucket permissions", + bucket_not_found: "The specified bucket does not exist or is not accessible", + endpoint_unreachable: "The endpoint URL is unreachable", + rate_limited: "The S3 endpoint is rate limiting requests", + s3_down: "The S3 service or endpoint is currently unavailable", +}; export const DatabaseSelect = { id: database.id, teamId: database.teamId, - createdBy: database.createdBy, + + status: database.status, + errorMessage: database.errorMessage, + name: database.name, endpoint: database.endpoint, region: database.region, type: database.type, + + createdBy: database.createdBy, createdAt: database.createdAt, + lastUsed: database.lastUsed, + lastRefreshedAt: database.lastRefreshedAt, }; export type Database = InferDrizzleSelect; +export const DatabaseRotateSchema = z.object({ + accessKey: z.string().min(1).max(256), + secretKey: z.string().min(1).max(256), +}); + export const DatabaseInfo = { type: database.type, name: database.name, diff --git a/src/lib/types/roblox-credentials-types.ts b/src/lib/types/roblox-credentials-types.ts new file mode 100644 index 0000000..7589547 --- /dev/null +++ b/src/lib/types/roblox-credentials-types.ts @@ -0,0 +1,62 @@ +import { roblox_credentials } from "@/src/db/schema/roblox_credentials"; +import { InferDrizzleSelect } from "../utils"; +import z from "zod"; + +export type RobloxCredentialStatus = + typeof roblox_credentials.$inferSelect.status; + +export const RobloxCredentialSelect = { + id: roblox_credentials.id, + teamId: roblox_credentials.teamId, + status: roblox_credentials.status, + errorMessage: roblox_credentials.errorMessage, + name: roblox_credentials.name, + expirationDate: roblox_credentials.expirationDate, + keyOwnerRobloxId: roblox_credentials.keyOwnerRobloxId, + createdAt: roblox_credentials.createdAt, + lastUsed: roblox_credentials.lastUsed, + lastRefreshedAt: roblox_credentials.lastRefreshedAt, +}; +export type RobloxCredential = InferDrizzleSelect< + typeof RobloxCredentialSelect +>; + +export const RobloxCredentialInfo = { + name: roblox_credentials.name, + key: roblox_credentials.keyCiphertext, +}; +export type RobloxCredentialInfo = InferDrizzleSelect< + typeof RobloxCredentialInfo +>; +export const RobloxCredentialInfoSchema = z.object({ + name: z + .string() + .min(3, { error: "Name must be at least 3 characters" }) + .max(32, { error: "Name must be at most 32 characters" }), + key: z + .string() + .min(1, { error: "Key must contain at least 1 character" }) + .max(2048, { error: "Key must be at most 2048 characters" }), +}); + +export const RobloxCredentialRenameSchema = z.object({ + name: z + .string() + .min(3, { error: "Name must be at least 3 characters" }) + .max(32, { error: "Name must be at most 32 characters" }), +}); + +export const RobloxCredentialRotateSchema = z.object({ + key: z + .string() + .min(1, { error: "Key must contain at least 1 character" }) + .max(2048, { error: "Key must be at most 2048 characters" }), +}); + +export const roblox_credential_status = { + expires_soon: "This key expires in less than a week", + expired: "This key has reached its expiration date", + disabled: "This key is disabled in the Roblox Creator Dashboard", + rate_limit: "Roblox is rate-limiting this key", + roblox_down: "Roblox servers are currently unreachable", +}; diff --git a/src/lib/utils/errors.ts b/src/lib/utils/api-utils.ts similarity index 78% rename from src/lib/utils/errors.ts rename to src/lib/utils/api-utils.ts index 1f00ca3..0c37ad2 100644 --- a/src/lib/utils/errors.ts +++ b/src/lib/utils/api-utils.ts @@ -1,4 +1,7 @@ import { NextResponse } from "next/server"; +import { createLogger } from "./logger"; + +const logger = createLogger("api-utils"); export type ErrorResponse = { code: string; @@ -28,7 +31,7 @@ export function ErrorToNextResponse(error: unknown): NextResponse { ); } - console.error("Unhandled server error:", (error as any).message ?? error); + logger.error("Unhandled server error", error); return NextResponse.json( { error: "Unknown server error. Please try again later." }, { status: 500 }, @@ -56,3 +59,17 @@ export const AuthenticationRequired = new ApiError( "AuthenticationRequired", "You must reauthenticate to perform this action", ); + +export async function fetcher( + url: string, + options?: RequestInit, +): Promise { + const response = await fetch(url, options); + const data = await response.json(); + + if (!response.ok) { + throw ResponseToError(data); + } + + return data as T; +} diff --git a/src/lib/utils/logger.ts b/src/lib/utils/logger.ts new file mode 100644 index 0000000..78c3b3a --- /dev/null +++ b/src/lib/utils/logger.ts @@ -0,0 +1,54 @@ +type LogLevel = "info" | "warn" | "error" | "debug"; + +interface LogContext { + teamId?: string; + actorId?: string; + resourceId?: string; + operation?: string; + [key: string]: string | undefined; +} + +function formatContext(context?: LogContext): string { + if (!context) return ""; + const parts = Object.entries(context) + .filter(([, v]) => v !== undefined) + .map(([k, v]) => `${k}=${v}`); + return parts.length > 0 ? " | " + parts.join(" ") : ""; +} + +function log( + level: LogLevel, + service: string, + message: string, + error?: unknown, + context?: LogContext, +): void { + const timestamp = new Date().toISOString(); + const tag = `[${level.toUpperCase()}] [${service}]`; + const ctx = formatContext(context); + + if (error !== undefined) { + const name = error instanceof Error ? error.name : "UnknownError"; + const msg = error instanceof Error ? error.message : String(error); + console.log(`${timestamp} ${tag} ${message} | errorName=${name} errorMessage=${msg}${ctx}`); + } else { + console.log(`${timestamp} ${tag} ${message}${ctx}`); + } +} + +export function createLogger(service: string) { + return { + info(message: string, context?: LogContext) { + log("info", service, message, undefined, context); + }, + warn(message: string, context?: LogContext) { + log("warn", service, message, undefined, context); + }, + error(message: string, error?: unknown, context?: LogContext) { + log("error", service, message, error, context); + }, + debug(message: string, context?: LogContext) { + log("debug", service, message, undefined, context); + }, + }; +} diff --git a/src/lib/utils/team-utils.ts b/src/lib/utils/team-utils.ts index 08f7153..10ea171 100644 --- a/src/lib/utils/team-utils.ts +++ b/src/lib/utils/team-utils.ts @@ -20,6 +20,15 @@ export const TEAM_PERMISSIONS = { LinkDatabase: ROLES_RANK.admin, DeleteDatabase: ROLES_RANK.admin, RenameDatabase: ROLES_RANK.admin, + RefreshDatabase: ROLES_RANK.admin, + RotateDatabaseCredentials: ROLES_RANK.admin, + + ListRobloxCredentials: ROLES_RANK.viewer, + LinkRobloxCredential: ROLES_RANK.admin, + DeleteRobloxCredential: ROLES_RANK.admin, + RenameRobloxCredential: ROLES_RANK.admin, + RotateRobloxCredential: ROLES_RANK.admin, + RefreshRobloxCredential: ROLES_RANK.admin, } as const satisfies Record; export type TeamAction = keyof typeof TEAM_PERMISSIONS; diff --git a/src/lib/utils/url-utils.ts b/src/lib/utils/url-utils.ts new file mode 100644 index 0000000..c755ad8 --- /dev/null +++ b/src/lib/utils/url-utils.ts @@ -0,0 +1,23 @@ +const PRIVATE_IP_RE = [ + /^localhost$/i, + /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, + /^172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}$/, + /^192\.168\.\d{1,3}\.\d{1,3}$/, + /^169\.254\.\d{1,3}\.\d{1,3}$/, + /^0\.\d{1,3}\.\d{1,3}\.\d{1,3}$/, +]; + +export function isSafeEndpointUrl(raw: string): boolean { + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return false; + } + + if (parsed.protocol !== "https:") return false; + + const host = parsed.hostname; + return !PRIVATE_IP_RE.some((re) => re.test(host)); +} diff --git a/src/services/ExternalDatabaseService.ts b/src/services/ExternalDatabaseService.ts index 4214a04..979083d 100755 --- a/src/services/ExternalDatabaseService.ts +++ b/src/services/ExternalDatabaseService.ts @@ -2,12 +2,20 @@ import { and, eq } from "drizzle-orm"; import { db } from "../db"; import { database } from "../db/schema"; import { HeadBucketCommand, S3Client } from "@aws-sdk/client-s3"; -import { EncryptString256 } from "../lib/crypto/aes"; +import { DecryptString256, EncryptString256 } from "../lib/crypto/aes"; import { randomUUID } from "crypto"; -import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/errors"; -import { Database, DatabaseSelect } from "../lib/types/database-types"; +import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; +import { + Database, + DatabaseSelect, + DatabaseStatus, + database_status, +} from "../lib/types/database-types"; import { TeamService } from "./TeamService"; import { hasPermission } from "../lib/utils/team-utils"; +import { createLogger } from "../lib/utils/logger"; + +const logger = createLogger("ExternalDatabaseService"); export interface DatabaseCredentials { AccessKeyID: string; @@ -24,6 +32,175 @@ const InvalidS3Credentials = new ApiError( ); export const ExternalDatabaseService = { + // Internal Methods + async _setDbStatus( + databaseId: string, + info: { kind: DatabaseStatus; message?: string }, + tx?: any, + ) { + const client = tx ?? db; + return await client + .update(database) + .set({ + status: info.kind, + errorMessage: info.kind == "healthy" ? null : (info.message ?? null), + }) + .where(eq(database.id, databaseId)); + }, + + async _useDatabase( + databaseId: string, + teamId: string, + callback: ( + creds: DatabaseCredentials, + setStatus: (kind: DatabaseStatus, message?: string) => Promise, + tx: Parameters[0]>[0], + ) => T | Promise, + ): Promise { + return await db.transaction(async (tx) => { + let dbInfo: typeof database.$inferSelect; + try { + [dbInfo] = await tx + .select() + .from(database) + .where(and(eq(database.id, databaseId), eq(database.teamId, teamId))); + } catch { + throw DatabaseError; + } + + if (!dbInfo) { + throw AccessDenied; + } + + let decryptedAk: string; + let decryptedSk: string; + try { + decryptedAk = DecryptString256({ + encryptedData: dbInfo.akCiphertext, + initializationVector: dbInfo.akIv, + authTag: dbInfo.akTag, + }); + decryptedSk = DecryptString256({ + encryptedData: dbInfo.skCiphertext, + initializationVector: dbInfo.skIv, + authTag: dbInfo.skTag, + }); + } catch { + throw new ApiError( + 500, + "DATABASE_CRED_DECRYPTION_ERROR", + "There was an error while accessing your database credentials. If the problem persists, please contact support.", + ); + } + + const setStatus = async (kind: DatabaseStatus, message?: string) => { + try { + await this._setDbStatus(databaseId, { kind, message }, tx); + } catch { + throw DatabaseError; + } + }; + + try { + await tx + .update(database) + .set({ lastUsed: new Date() }) + .where(eq(database.id, databaseId)); + } catch { + throw DatabaseError; + } + + return await callback( + { + AccessKeyID: decryptedAk, + SecretAccessKey: decryptedSk, + EndpointURL: dbInfo.endpoint, + Region: dbInfo.region, + BucketName: dbInfo.bucketName, + }, + setStatus, + tx, + ); + }); + }, + + async _introspectDatabase( + creds: DatabaseCredentials, + ): Promise<{ status: DatabaseStatus; message?: string }> { + const s3Client = new S3Client({ + region: creds.Region, + endpoint: creds.EndpointURL, + credentials: { + accessKeyId: creds.AccessKeyID, + secretAccessKey: creds.SecretAccessKey, + }, + forcePathStyle: true, + }); + + try { + await s3Client.send(new HeadBucketCommand({ Bucket: creds.BucketName })); + return { status: "healthy" }; + } catch (error: any) { + const httpStatus = error.$metadata?.httpStatusCode; + const errorName = error.name; + + if ( + errorName === "SlowDown" || + errorName === "ThrottlingException" || + httpStatus === 429 + ) { + return { status: "warning", message: database_status.rate_limited }; + } else if (httpStatus === 503) { + return { status: "warning", message: database_status.s3_down }; + } else if ( + httpStatus === 403 || + errorName === "AccessDenied" || + errorName === "Forbidden" + ) { + return { + status: "error", + message: database_status.invalid_credentials, + }; + } else if (httpStatus === 404 || errorName === "NoSuchBucket") { + return { status: "error", message: database_status.bucket_not_found }; + } else if ( + error.code === "ENOTFOUND" || + error.code === "ECONNREFUSED" || + error.code === "ETIMEDOUT" + ) { + return { + status: "error", + message: database_status.endpoint_unreachable, + }; + } else { + return { status: "error", message: database_status.connection_failed }; + } + } + }, + + async _applyIntrospectResults( + databaseId: string, + result: { status: DatabaseStatus; message?: string }, + tx?: any, + ): Promise { + const client = tx ?? db; + try { + const [updated] = await client + .update(database) + .set({ + status: result.status, + errorMessage: + result.status === "healthy" ? null : (result.message ?? null), + lastRefreshedAt: new Date(), + }) + .where(eq(database.id, databaseId)) + .returning(DatabaseSelect); + return updated; + } catch { + throw DatabaseError; + } + }, + async CreateS3Client(Creds: DatabaseCredentials): Promise { const s3Client = new S3Client({ region: Creds.Region, @@ -56,7 +233,14 @@ export const ExternalDatabaseService = { } // Check if credentials are valid - await this.CreateS3Client(creds); // Will throw an error if credentials are invalid + const introspectResult = await this._introspectDatabase(creds); + if (introspectResult.status === "error") { + throw new ApiError( + 400, + "DATABASE_UNREACHABLE", + introspectResult.message ?? database_status.connection_failed, + ); + } // Encrypt sensitive data const encodedAkData = EncryptString256(creds.AccessKeyID); @@ -76,6 +260,9 @@ export const ExternalDatabaseService = { endpoint: creds.EndpointURL, region: creds.Region, + status: introspectResult.status, + errorMessage: introspectResult.message ?? null, + akCiphertext: encodedAkData.encryptedData, akIv: encodedAkData.initializationVector, akTag: encodedAkData.authTag, @@ -88,7 +275,7 @@ export const ExternalDatabaseService = { return newRecord; } catch (error) { - console.error("Database insertion failed: ", error); + logger.error("Database insertion failed", error, { teamId, operation: "LinkDatabase" }); throw DatabaseError; } }, @@ -107,7 +294,7 @@ export const ExternalDatabaseService = { return results; } catch (error) { - console.error("Error while fetching user's databases: ", error); + logger.error("Failed to fetch databases", error, { teamId, operation: "ListDatabase" }); throw DatabaseError; } }, @@ -130,7 +317,8 @@ export const ExternalDatabaseService = { if (!result) throw AccessDenied; return result; } catch (error) { - console.error(`Failed to delete database ${databaseId}: `, error); + if (error instanceof ApiError) throw error; + logger.error("Failed to delete database", error, { resourceId: databaseId, operation: "DeleteDatabase" }); throw DatabaseError; } }, @@ -155,8 +343,86 @@ export const ExternalDatabaseService = { if (!result) throw AccessDenied; return result; } catch (error) { - console.error(`Failed to rename database ${databaseId}: `, error); + if (error instanceof ApiError) throw error; + logger.error("Failed to rename database", error, { resourceId: databaseId, operation: "RenameDatabase" }); throw DatabaseError; } }, + + async RefreshDatabase( + actorId: string, + teamId: string, + databaseId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RefreshDatabase")) { + throw AccessDenied; + } + + return await this._useDatabase( + databaseId, + teamId, + async (creds, _setStatus, tx) => { + const updated = await this._applyIntrospectResults( + databaseId, + await this._introspectDatabase(creds), + tx, + ); + if (!updated) throw DatabaseError; + return updated; + }, + ); + }, + + async RotateDatabaseCredentials( + actorId: string, + teamId: string, + databaseId: string, + newCreds: Pick, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RotateDatabaseCredentials")) { + throw AccessDenied; + } + + return await this._useDatabase(databaseId, teamId, async (creds, _setStatus, tx) => { + const fullCreds: DatabaseCredentials = { + ...creds, + AccessKeyID: newCreds.AccessKeyID, + SecretAccessKey: newCreds.SecretAccessKey, + }; + + const introspectResult = await this._introspectDatabase(fullCreds); + if (introspectResult.status === "error") { + throw new ApiError( + 400, + "DATABASE_UNREACHABLE", + introspectResult.message ?? database_status.connection_failed, + ); + } + + const encodedAkData = EncryptString256(newCreds.AccessKeyID); + const encodedSkData = EncryptString256(newCreds.SecretAccessKey); + + try { + const [updated] = await tx + .update(database) + .set({ + akCiphertext: encodedAkData.encryptedData, + akIv: encodedAkData.initializationVector, + akTag: encodedAkData.authTag, + skCiphertext: encodedSkData.encryptedData, + skIv: encodedSkData.initializationVector, + skTag: encodedSkData.authTag, + }) + .where(eq(database.id, databaseId)) + .returning(DatabaseSelect); + if (!updated) throw AccessDenied; + return await this._applyIntrospectResults(databaseId, introspectResult, tx); + } catch (error) { + if (error instanceof ApiError) throw error; + throw DatabaseError; + } + }); + }, }; diff --git a/src/services/RobloxCredentialsService.ts b/src/services/RobloxCredentialsService.ts new file mode 100644 index 0000000..6854244 --- /dev/null +++ b/src/services/RobloxCredentialsService.ts @@ -0,0 +1,400 @@ +import { randomUUID } from "crypto"; +import { db } from "../db"; +import { roblox_credentials } from "../db/schema/roblox_credentials"; +import { DecryptString256, EncryptString256 } from "../lib/crypto/aes"; +import { + roblox_credential_status, + RobloxCredential, + RobloxCredentialInfo, + RobloxCredentialSelect, + RobloxCredentialStatus, +} from "../lib/types/roblox-credentials-types"; +import { AccessDenied, ApiError, DatabaseError } from "../lib/utils/api-utils"; +import { hasPermission } from "../lib/utils/team-utils"; +import { createLogger } from "../lib/utils/logger"; +import { TeamService } from "./TeamService"; +import { and, eq } from "drizzle-orm"; + +const logger = createLogger("RobloxCredentialsService"); + +export const RobloxCredentialsService = { + // Internal Methods + async _introspectKey(key: string) { + const response = await fetch( + "https://apis.roblox.com/api-keys/v1/introspect", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: key }), + }, + ); + + const data = await response.json(); + + if (!response.ok) { + throw new ApiError( + response.status, + "ROBLOX_INTROSPECT_ERROR", + data.message ?? + "There was an error while verifying your API key. Please try again later", + ); + } + + return data; + }, + + async _setKeyStatus( + credId: string, + info: { kind: RobloxCredentialStatus; message?: string }, + tx?: any, + ) { + const client = tx ?? db; + return await client + .update(roblox_credentials) + .set({ + status: info.kind, + errorMessage: info.kind == "healthy" ? null : (info.message ?? null), + }) + .where(eq(roblox_credentials.id, credId)); + }, + + async _useCredential( + credId: string, + teamId: string, + callback: ( + key: string, + setStatus: ( + kind: RobloxCredentialStatus, + message?: string, + ) => Promise, + tx: Parameters[0]>[0], + ) => T | Promise, + ): Promise { + return await db.transaction(async (tx) => { + let keyInfo: typeof roblox_credentials.$inferSelect; + try { + [keyInfo] = await tx + .select() + .from(roblox_credentials) + .where(and(eq(roblox_credentials.id, credId), eq(roblox_credentials.teamId, teamId))); + } catch { + throw DatabaseError; + } + + if (!keyInfo) { + throw AccessDenied; + } + + let decryptedKey; + try { + decryptedKey = DecryptString256({ + encryptedData: keyInfo.keyCiphertext, + initializationVector: keyInfo.keyIv, + authTag: keyInfo.keyTag, + }); + } catch { + throw new ApiError( + 500, + "ROBLOX_CRED_DECRYPTION_ERROR", + "There was an error while accessing your Roblox API Key. If the problem persists, please contact support.", + ); + } + + const setStatus = async ( + kind: RobloxCredentialStatus, + message?: string, + ) => { + try { + await this._setKeyStatus(credId, { kind, message: message }, tx); + } catch { + throw DatabaseError; + } + }; + + try { + await tx + .update(roblox_credentials) + .set({ lastUsed: new Date() }) + .where(eq(roblox_credentials.id, credId)); + } catch { + throw DatabaseError; + } + + return await callback(decryptedKey, setStatus, tx); + }); + }, + + async _applyIntrospectResults( + credId: string, + keyInfo: any, + tx?: Parameters[0]>[0], + ) { + let status: RobloxCredentialStatus = "healthy"; + let message: string | undefined = undefined; + + const client = tx ?? db; + + // Check if the credential is expiring soon (in less than a week) + const expDate = new Date(keyInfo.expirationTimeUtc); + const now = Date.now(); + const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + const isExpiringSoon = expDate.getTime() - now < ONE_WEEK_MS; + + if (!keyInfo.enabled) { + status = "error"; + message = roblox_credential_status.disabled; + } else if (keyInfo.expired) { + status = "error"; + message = roblox_credential_status.expired; + } else if (isExpiringSoon) { + status = "warning"; + message = roblox_credential_status.expires_soon; + } + + // Update the credential row with the new introspect data + try { + const [updated] = await client + .update(roblox_credentials) + .set({ + ...(keyInfo.expirationTimeUtc !== undefined + ? { expirationDate: new Date(keyInfo.expirationTimeUtc) } + : {}), + lastRefreshedAt: new Date(), + status, + errorMessage: message ?? null, + keyOwnerRobloxId: keyInfo.authorizedUserId, + }) + .where(eq(roblox_credentials.id, credId)) + .returning(RobloxCredentialSelect); + + // Return immediately the updated credential + return updated; + } catch { + throw DatabaseError; + } + }, + + // API Methods + async LinkRobloxCredential( + actorId: string, + teamId: string, + creds: RobloxCredentialInfo, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + + if (!hasPermission(actorRole, "LinkRobloxCredential")) { + throw AccessDenied; + } + + let finalStatus: RobloxCredentialStatus = "healthy"; + let message; + const keyInfo = await this._introspectKey(creds.key); + + if (!keyInfo.enabled) { + finalStatus = "error"; + message = roblox_credential_status.disabled; + } else if (keyInfo.expired) { + finalStatus = "error"; + message = roblox_credential_status.expired; + } + + const encodedKey = EncryptString256(creds.key); + try { + const [newRecord] = await db + .insert(roblox_credentials) + .values({ + id: randomUUID(), + teamId, + name: creds.name, + status: finalStatus, + errorMessage: message, + keyCiphertext: encodedKey.encryptedData, + keyIv: encodedKey.initializationVector, + keyTag: encodedKey.authTag, + expirationDate: keyInfo.expirationTimeUtc + ? new Date(keyInfo.expirationTimeUtc) + : null, + keyOwnerRobloxId: keyInfo.authorizedUserId, + createdBy: actorId, + }) + .returning(RobloxCredentialSelect); + + return newRecord; + } catch { + throw DatabaseError; + } + }, + + async DeleteRobloxCredential( + actorId: string, + teamId: string, + credId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "DeleteRobloxCredential")) { + throw AccessDenied; + } + + try { + const [result] = await db + .delete(roblox_credentials) + .where( + and( + eq(roblox_credentials.id, credId), + eq(roblox_credentials.teamId, teamId), + ), + ) + .returning(RobloxCredentialSelect); + if (!result) throw AccessDenied; + return result; + } catch { + throw DatabaseError; + } + }, + + async RenameRobloxCredential( + actorId: string, + teamId: string, + credId: string, + newName: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RenameRobloxCredential")) { + throw AccessDenied; + } + + try { + const [result] = await db + .update(roblox_credentials) + .set({ name: newName }) + .where( + and( + eq(roblox_credentials.id, credId), + eq(roblox_credentials.teamId, teamId), + ), + ) + .returning(RobloxCredentialSelect); + if (!result) throw AccessDenied; + return result; + } catch { + throw DatabaseError; + } + }, + + async RotateRobloxCredential( + actorId: string, + teamId: string, + credId: string, + newKey: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RotateRobloxCredential")) { + throw AccessDenied; + } + + const keyInfo = await this._introspectKey(newKey); + if (!keyInfo.enabled) { + throw new ApiError( + 400, + "ROTATION_FAILED", + "The new key is disabled. Please enable the key in the Roblox Creator Dashboard before rotating", + ); + } else if (keyInfo.expired) { + throw new ApiError( + 400, + "ROTATION_FAILED", + "The new key is expired. Please refresh it in the Roblox Creator Dashboard before rotating", + ); + } + + const newKeyEncrypted = EncryptString256(newKey); + return await db.transaction(async (tx) => { + await tx + .update(roblox_credentials) + .set({ + keyCiphertext: newKeyEncrypted.encryptedData, + keyIv: newKeyEncrypted.initializationVector, + keyTag: newKeyEncrypted.authTag, + }) + .where(eq(roblox_credentials.id, credId)); + + return this._applyIntrospectResults(credId, keyInfo, tx); + }); + }, + + async ListTeamRobloxCredentials( + actorId: string, + teamId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "ListRobloxCredentials")) { + throw AccessDenied; + } + + try { + const results = await db + .select(RobloxCredentialSelect) + .from(roblox_credentials) + .where(eq(roblox_credentials.teamId, teamId)); + return results; + } catch { + throw DatabaseError; + } + }, + + async RefreshRobloxCredential( + actorId: string, + teamId: string, + credId: string, + ): Promise { + const actorRole = await TeamService.GetTeamUserRole(actorId, teamId); + if (!hasPermission(actorRole, "RefreshRobloxCredential")) { + throw AccessDenied; + } + + return await this._useCredential(credId, teamId, async (key, setStatus, tx) => { + // Try to fetch the credential info using _introspectKey + let keyInfo; + try { + keyInfo = await this._introspectKey(key); + } catch (error) { + if (error instanceof ApiError) { + if (error.status === 429) { + await setStatus("warning", roblox_credential_status.rate_limit); + } else if (error.status >= 500) { + await setStatus("warning", roblox_credential_status.roblox_down); + } else { + await setStatus("error", error.clientMessage); + } + } else { + throw error; + } + } + + // Update credential row + // Skip if _introspectKey rejected + if (keyInfo) { + try { + const updatedCred = await this._applyIntrospectResults( + credId, + keyInfo, + tx, + ); + if (updatedCred) return updatedCred; + } catch (error) { + logger.error("Failed to apply introspect results", error, { resourceId: credId }); + } + } + + // Return the final credential info (fallback for when _introspectKey rejected) + const result = await tx + .select(RobloxCredentialSelect) + .from(roblox_credentials) + .where(eq(roblox_credentials.id, credId)) + .get(); + + if (!result) throw DatabaseError; + return result; + }); + }, +}; diff --git a/src/services/TeamService.test.ts b/src/services/TeamService.test.ts index 7083d8f..5846098 100644 --- a/src/services/TeamService.test.ts +++ b/src/services/TeamService.test.ts @@ -3,7 +3,7 @@ import { db } from "@/src/db"; import { TeamService } from "./TeamService"; import { team_member, user } from "../db/schema"; import { and, eq } from "drizzle-orm"; -import { UserNotFound } from "../lib/utils/errors"; +import { UserNotFound } from "../lib/utils/api-utils"; import { TeamRole } from "../lib/types/team-types"; async function createUser(name: string) { diff --git a/src/services/TeamService.ts b/src/services/TeamService.ts index 5531f89..cfd0812 100644 --- a/src/services/TeamService.ts +++ b/src/services/TeamService.ts @@ -7,7 +7,7 @@ import { ApiError, DatabaseError, UserNotFound, -} from "../lib/utils/errors"; +} from "../lib/utils/api-utils"; import { randomUUID } from "crypto"; import { team_member } from "../db/schema/team_member"; import { CheckUserExist } from "../lib/utils/auth-utils";