mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
chore(deps): Update project dependencies and refactor authentication flow
- Upgrade Next.js to version 15.1.7 - Update Drizzle ORM and related dependencies - Add Nodemailer and related type definitions - Refactor authentication routes and components - Modify user schema to include first and last name - Update authentication configuration and session handling - Remove deprecated login and register pages - Restructure authentication-related components and routes
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# HRIStudio
|
||||
|
||||
A modern web application for managing human-robot interaction studies, built with Next.js 14, TypeScript, and the App Router.
|
||||
A modern web application for managing human-robot interaction studies, built with Next.js 15, TypeScript, and the App Router.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework**: Next.js 14 with App Router
|
||||
- **Framework**: Next.js 15 with App Router
|
||||
- **Language**: TypeScript
|
||||
- **Authentication**: NextAuth.js
|
||||
- **Database**: PostgreSQL with Drizzle ORM
|
||||
|
||||
142
bun.lock
142
bun.lock
@@ -9,6 +9,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
@@ -18,26 +19,27 @@
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"framer-motion": "^12.0.6",
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "^15.0.1",
|
||||
"next": "^15.1.7",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.10.0",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -58,7 +60,7 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
@@ -289,25 +291,25 @@
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.25", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ=="],
|
||||
|
||||
"@next/env": ["@next/env@15.1.6", "", {}, "sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w=="],
|
||||
"@next/env": ["@next/env@15.1.7", "", {}, "sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ=="],
|
||||
|
||||
"@next/eslint-plugin-next": ["@next/eslint-plugin-next@15.1.6", "", { "dependencies": { "fast-glob": "3.3.1" } }, "sha512-+slMxhTgILUntZDGNgsKEYHUvpn72WP1YTlkmEhS51vnVd7S9jEEy0n9YAMcI21vUG4akTw9voWH02lrClt/yw=="],
|
||||
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-u7lg4Mpl9qWpKgy6NzEkz/w0/keEHtOybmIl0ykgItBxEM5mYotS5PmqTpo+Rhg8FiOiWgwr8USxmKQkqLBCrw=="],
|
||||
"@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.1.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ=="],
|
||||
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg=="],
|
||||
"@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.1.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ=="],
|
||||
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg=="],
|
||||
"@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA=="],
|
||||
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.1.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ=="],
|
||||
"@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.1.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w=="],
|
||||
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ=="],
|
||||
"@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ=="],
|
||||
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ=="],
|
||||
"@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.1.7", "", { "os": "linux", "cpu": "x64" }, "sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ=="],
|
||||
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.1.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg=="],
|
||||
"@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.1.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q=="],
|
||||
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ=="],
|
||||
"@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.1.7", "", { "os": "win32", "cpu": "x64" }, "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
@@ -325,6 +327,8 @@
|
||||
|
||||
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.1", "", {}, "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dialog": "1.1.6", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ=="],
|
||||
|
||||
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.1", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w=="],
|
||||
|
||||
"@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.2", "", { "dependencies": { "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-layout-effect": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig=="],
|
||||
@@ -363,9 +367,9 @@
|
||||
|
||||
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-use-layout-effect": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg=="],
|
||||
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
"@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.2", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w=="],
|
||||
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw=="],
|
||||
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.2", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw=="],
|
||||
|
||||
"@radix-ui/react-select": ["@radix-ui/react-select@2.1.5", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.1", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-visually-hidden": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-eVV7N8jBXAXnyrc+PsOF89O9AfVgGnbLxUtBb0clJ8y8ENMWLARGMI/1/SBRLz7u4HqxLgN71BJ17eono3wcjA=="],
|
||||
|
||||
@@ -373,11 +377,9 @@
|
||||
|
||||
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.2.2", "", { "dependencies": { "@radix-ui/number": "1.1.0", "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-size": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sNlU06ii1/ZcbHf8I9En54ZPW0Vil/yPVg4vQMcFNjrIx51jsHbFl1HYHQvCIWJSr1q0ZmA+iIs/ZTv8h7HHSA=="],
|
||||
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ=="],
|
||||
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.2", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-roving-focus": "1.1.1", "@radix-ui/react-use-controllable-state": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ=="],
|
||||
|
||||
"@radix-ui/react-toast": ["@radix-ui/react-toast@1.2.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-layout-effect": "1.1.0", "@radix-ui/react-visually-hidden": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ZzUsAaOx8NdXZZKcFNDhbSlbsCUy8qQWmzTdgrlrhhZAOx2ofLtKrBDW9fkqhFvXgmtv560Uj16pkLkqML7SHA=="],
|
||||
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-roving-focus": "1.1.2", "@radix-ui/react-use-controllable-state": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng=="],
|
||||
|
||||
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.1.7", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.4", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.1", "@radix-ui/react-portal": "1.1.3", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-slot": "1.1.1", "@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-visually-hidden": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-ss0s80BC0+g0+Zc53MvilcnTYSOi4mSuFWBPYPuTOFGjx+pUU+ZrmamMNwS56t8MTFlniA5ocjd4jYm/CdhbOg=="],
|
||||
|
||||
@@ -533,6 +535,8 @@
|
||||
|
||||
"@types/node": ["@types/node@20.17.16", "", { "dependencies": { "undici-types": "~6.19.2" } }, "sha512-vOTpLduLkZXePLxHiHsBLp98mHGnl8RptV4YAO3HfKO5UHjDvySGbxKtpYfy8Sx5+WKcgc45qNreJJRVM3L6mw=="],
|
||||
|
||||
"@types/nodemailer": ["@types/nodemailer@6.4.17", "", { "dependencies": { "@types/node": "*" } }, "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww=="],
|
||||
|
||||
"@types/prop-types": ["@types/prop-types@15.7.14", "", {}, "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ=="],
|
||||
|
||||
"@types/react": ["@types/react@18.3.18", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" } }, "sha512-t4yC+vtgnkYjNSKlFx1jkAhH8LgTo2N/7Qvi83kdEaUtMDiwpbLAktKDaAMlRcJ5eSxZkH74eEGt1ky31d7kfQ=="],
|
||||
@@ -699,9 +703,9 @@
|
||||
|
||||
"doctrine": ["doctrine@3.0.0", "", { "dependencies": { "esutils": "^2.0.2" } }, "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w=="],
|
||||
|
||||
"drizzle-kit": ["drizzle-kit@0.24.2", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.1", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-nXOaTSFiuIaTMhS8WJC2d4EBeIcN9OSt2A2cyFbQYBAZbi7lRsVGJNqDpEwPqYfJz38yxbY/UtbvBBahBfnExQ=="],
|
||||
"drizzle-kit": ["drizzle-kit@0.30.4", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.19.7", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ=="],
|
||||
|
||||
"drizzle-orm": ["drizzle-orm@0.33.0", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=3", "@electric-sql/pglite": ">=0.1.1", "@libsql/client": "*", "@neondatabase/serverless": ">=0.1", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/react": ">=18", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=13.2.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "react": ">=18", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/react", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "react", "sql.js", "sqlite3"] }, "sha512-SHy72R2Rdkz0LEq0PSG/IdvnT3nGiWuRk+2tXZQ90GVq/XQhpCzu/EFT3V2rox+w8MlkBQxifF8pCStNYnERfA=="],
|
||||
"drizzle-orm": ["drizzle-orm@0.39.3", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
@@ -993,10 +997,12 @@
|
||||
|
||||
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||
|
||||
"next": ["next@15.1.6", "", { "dependencies": { "@next/env": "15.1.6", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.1.6", "@next/swc-darwin-x64": "15.1.6", "@next/swc-linux-arm64-gnu": "15.1.6", "@next/swc-linux-arm64-musl": "15.1.6", "@next/swc-linux-x64-gnu": "15.1.6", "@next/swc-linux-x64-musl": "15.1.6", "@next/swc-win32-arm64-msvc": "15.1.6", "@next/swc-win32-x64-msvc": "15.1.6", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-Hch4wzbaX0vKQtalpXvUiw5sYivBy4cm5rzUKrBnUB/y436LGrvOUqYvlSeNVCWFO/770gDlltR9gqZH62ct4Q=="],
|
||||
"next": ["next@15.1.7", "", { "dependencies": { "@next/env": "15.1.7", "@swc/counter": "0.1.3", "@swc/helpers": "0.5.15", "busboy": "1.6.0", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.1.7", "@next/swc-darwin-x64": "15.1.7", "@next/swc-linux-arm64-gnu": "15.1.7", "@next/swc-linux-arm64-musl": "15.1.7", "@next/swc-linux-x64-gnu": "15.1.7", "@next/swc-linux-x64-musl": "15.1.7", "@next/swc-win32-arm64-msvc": "15.1.7", "@next/swc-win32-x64-msvc": "15.1.7", "sharp": "^0.33.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.41.2", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg=="],
|
||||
|
||||
"next-auth": ["next-auth@4.24.11", "", { "dependencies": { "@babel/runtime": "^7.20.13", "@panva/hkdf": "^1.0.2", "cookie": "^0.7.0", "jose": "^4.15.5", "oauth": "^0.9.15", "openid-client": "^5.4.0", "preact": "^10.6.3", "preact-render-to-string": "^5.1.19", "uuid": "^8.3.2" }, "peerDependencies": { "@auth/core": "0.34.2", "next": "^12.2.5 || ^13 || ^14 || ^15", "nodemailer": "^6.6.5", "react": "^17.0.2 || ^18 || ^19", "react-dom": "^17.0.2 || ^18 || ^19" }, "optionalPeers": ["@auth/core", "nodemailer"] }, "sha512-pCFXzIDQX7xmHFs4KVH4luCjaCbuPRtZ9oBUjUhOk84mZ9WVPf94n87TxYI4rSRf9HmfHEF8Yep3JrYDVOo3Cw=="],
|
||||
|
||||
"nodemailer": ["nodemailer@6.10.0", "", {}, "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"normalize-wheel": ["normalize-wheel@1.0.1", "", {}, "sha512-1OnlAPZ3zgrk8B91HyRj+eVv+kS5u+Z0SCsak6Xil/kmgEia50ga7zfkumayonZrImffAxPU/5WcyGhzetHNPA=="],
|
||||
@@ -1321,6 +1327,60 @@
|
||||
|
||||
"@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.5", "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.2", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-portal": "1.1.4", "@radix-ui/react-presence": "1.1.2", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "1.1.2", "@radix-ui/react-use-controllable-state": "1.1.0", "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-collapsible/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-label/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.1", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-collection": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-direction": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-primitive": "2.0.1", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-controllable-state": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw=="],
|
||||
|
||||
"@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-roving-focus/@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-context": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-slider/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"@smithy/middleware-retry/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
@@ -1329,6 +1389,8 @@
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"cmdk/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.0.1", "", { "dependencies": { "@radix-ui/react-slot": "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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg=="],
|
||||
|
||||
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
|
||||
"eslint-module-utils/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
|
||||
@@ -1421,8 +1483,40 @@
|
||||
|
||||
"@next/eslint-plugin-next/fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.5", "", { "dependencies": { "@radix-ui/primitive": "1.1.1", "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.1.0", "@radix-ui/react-use-escape-keydown": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.2", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1", "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-callback-ref": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA=="],
|
||||
|
||||
"@radix-ui/react-alert-dialog/@radix-ui/react-dialog/@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.4", "", { "dependencies": { "@radix-ui/react-primitive": "2.0.2", "@radix-ui/react-use-layout-effect": "1.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" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA=="],
|
||||
|
||||
"@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-avatar/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-collapsible/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-separator/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-slider/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"cmdk/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g=="],
|
||||
|
||||
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA=="],
|
||||
|
||||
"string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="],
|
||||
|
||||
5
drizzle/meta/_journal.json
Normal file
5
drizzle/meta/_journal.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": []
|
||||
}
|
||||
556
package-lock.json
generated
556
package-lock.json
generated
@@ -13,6 +13,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
@@ -22,26 +23,28 @@
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.2",
|
||||
"@radix-ui/react-toast": "^1.2.5",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"framer-motion": "^12.0.6",
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "^15.0.1",
|
||||
"next": "^15.1.7",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.10.0",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -62,7 +65,7 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
@@ -1346,7 +1349,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.1.6",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.1.7.tgz",
|
||||
"integrity": "sha512-d9jnRrkuOH7Mhi+LHav2XW91HOgTAWHxjMPkXMGBc9B2b7614P7kjt8tAplRvJpbSt4nbO1lugcT/kAaWzjlLQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -1384,7 +1389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-arm64": {
|
||||
"version": "15.1.6",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.7.tgz",
|
||||
"integrity": "sha512-hPFwzPJDpA8FGj7IKV3Yf1web3oz2YsR8du4amKw8d+jAOHfYHYFpMkoF6vgSY4W6vB29RtZEklK9ayinGiCmQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1398,12 +1405,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-darwin-x64": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.6.tgz",
|
||||
"integrity": "sha512-x1jGpbHbZoZ69nRuogGL2MYPLqohlhnT9OCU6E6QFewwup+z+M6r8oU47BTeJcWsF2sdBahp5cKiAcDbwwK/lg==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.7.tgz",
|
||||
"integrity": "sha512-2qoas+fO3OQKkU0PBUfwTiw/EYpN+kdAx62cePRyY1LqKtP09Vp5UcUntfZYajop5fDFTjSxCHfZVRxzi+9FYQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
@@ -1413,12 +1421,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-gnu": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.6.tgz",
|
||||
"integrity": "sha512-jar9sFw0XewXsBzPf9runGzoivajeWJUc/JkfbLTC4it9EhU8v7tCRLH7l5Y1ReTMN6zKJO0kKAGqDk8YSO2bg==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.7.tgz",
|
||||
"integrity": "sha512-sKLLwDX709mPdzxMnRIXLIT9zaX2w0GUlkLYQnKGoXeWUhcvpCrK+yevcwCJPdTdxZEUA0mOXGLdPsGkudGdnA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1428,12 +1437,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-arm64-musl": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.6.tgz",
|
||||
"integrity": "sha512-+n3u//bfsrIaZch4cgOJ3tXCTbSxz0s6brJtU3SzLOvkJlPQMJ+eHVRi6qM2kKKKLuMY+tcau8XD9CJ1OjeSQQ==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.7.tgz",
|
||||
"integrity": "sha512-zblK1OQbQWdC8fxdX4fpsHDw+VSpBPGEUX4PhSE9hkaWPrWoeIJn+baX53vbsbDRaDKd7bBNcXRovY1hEhFd7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1443,12 +1453,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-gnu": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.6.tgz",
|
||||
"integrity": "sha512-SpuDEXixM3PycniL4iVCLyUyvcl6Lt0mtv3am08sucskpG0tYkW1KlRhTgj4LI5ehyxriVVcfdoxuuP8csi3kQ==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.7.tgz",
|
||||
"integrity": "sha512-GOzXutxuLvLHFDAPsMP2zDBMl1vfUHHpdNpFGhxu90jEzH6nNIgmtw/s1MDwpTOiM+MT5V8+I1hmVFeAUhkbgQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1458,12 +1469,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-linux-x64-musl": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.6.tgz",
|
||||
"integrity": "sha512-L4druWmdFSZIIRhF+G60API5sFB7suTbDRhYWSjiw0RbE+15igQvE2g2+S973pMGvwN3guw7cJUjA/TmbPWTHQ==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.7.tgz",
|
||||
"integrity": "sha512-WrZ7jBhR7ATW1z5iEQ0ZJfE2twCNSXbpCSaAunF3BKcVeHFADSI/AW1y5Xt3DzTqPF1FzQlwQTewqetAABhZRQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
@@ -1473,12 +1485,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-arm64-msvc": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.6.tgz",
|
||||
"integrity": "sha512-s8w6EeqNmi6gdvM19tqKKWbCyOBvXFbndkGHl+c9YrzsLARRdCHsD9S1fMj8gsXm9v8vhC8s3N8rjuC/XrtkEg==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.7.tgz",
|
||||
"integrity": "sha512-LDnj1f3OVbou1BqvvXVqouJZKcwq++mV2F+oFHptToZtScIEnhNRJAhJzqAtTE2dB31qDYL45xJwrc+bLeKM2Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1488,12 +1501,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/swc-win32-x64-msvc": {
|
||||
"version": "15.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.6.tgz",
|
||||
"integrity": "sha512-6xomMuu54FAFxttYr5PJbEfu96godcxBTRk1OhAvJq0/EnmFU/Ybiax30Snis4vdWZ9LGpf7Roy5fSs7v/5ROQ==",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.7.tgz",
|
||||
"integrity": "sha512-dC01f1quuf97viOfW05/K8XYv2iuBgAxJZl7mbCKEjMgdQl5JjAKJ0D2qMKZCgPWDeFbFT0Q0nYWwytEW0DWTQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
@@ -1562,6 +1576,57 @@
|
||||
"version": "1.1.1",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.6.tgz",
|
||||
"integrity": "sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dialog": "1.1.6",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-slot": "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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-alert-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
@@ -1659,6 +1724,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
@@ -1686,23 +1769,124 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.5",
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.6.tgz",
|
||||
"integrity": "sha512-/IVhJV5AceX620DUJ4uYVMymzsipdKBzo3edo+omeskCKGm9FRHM0ebIdbPnlQVJqyuHbuBltQUOG2mOTq2IYw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.4",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.5",
|
||||
"@radix-ui/react-focus-guards": "1.1.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.2",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-portal": "1.1.4",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-slot": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"react-remove-scroll": "^2.6.2"
|
||||
"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-dismissable-layer": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.5.tgz",
|
||||
"integrity": "sha512-E4TywXY6UsXNRhFrECa5HAvE5/4BFcGyfTyK36gP+pAW1ed7UTK4vKwdr53gAJYwqbfCWC6ATvJa3J3R/9+Qrg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-escape-keydown": "1.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-dialog/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.2.tgz",
|
||||
"integrity": "sha512-zxwE80FCU7lcXUGWkdt6XpTTCKPitG1XKOwViTxHVKIJhZl9MvIl2dVHeZENCWD9+EdWv05wlaEkRXUykU27RA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-callback-ref": "1.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-dialog/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.4.tgz",
|
||||
"integrity": "sha512-sn2O9k1rPFYVyKd5LAJfo96JlSGVFpa1fS6UuBJfrZadudiw5tAmru+n1x7aMRQ84qDM71Zh1+SzK5QwU0tJfA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-layout-effect": "1.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-dialog/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -1895,6 +2079,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-popover": {
|
||||
"version": "1.1.5",
|
||||
"license": "MIT",
|
||||
@@ -1930,6 +2132,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-popper": {
|
||||
"version": "1.2.1",
|
||||
"license": "MIT",
|
||||
@@ -2025,6 +2245,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-roving-focus": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
@@ -2095,6 +2333,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-separator": {
|
||||
"version": "1.1.1",
|
||||
"license": "MIT",
|
||||
@@ -2150,7 +2406,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
@@ -2165,10 +2423,62 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-switch": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.1.3.tgz",
|
||||
"integrity": "sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.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-switch/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz",
|
||||
"integrity": "sha512-9u/tQJMcC2aGq7KXpGivMm1mgq7oRJKXphDwdypPd/j21j/2znamPU8WkXgnhUaTrSFNIt8XhOyCAupg8/GbwQ==",
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.3.tgz",
|
||||
"integrity": "sha512-9mFyI30cuRDImbmFF6O2KUJdgEOsGh9Vmx9x/Dh9tOhL7BngmQPQfwW4aejKm5OHpfWIdmeV6ySyuxoOGjtNng==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
@@ -2176,8 +2486,8 @@
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-roving-focus": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-roving-focus": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -2195,24 +2505,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-toast": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.5.tgz",
|
||||
"integrity": "sha512-ZzUsAaOx8NdXZZKcFNDhbSlbsCUy8qQWmzTdgrlrhhZAOx2ofLtKrBDW9fkqhFvXgmtv560Uj16pkLkqML7SHA==",
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
|
||||
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-slot": "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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
|
||||
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "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"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tabs/node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
|
||||
"integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.2",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.4",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-visually-hidden": "1.1.1"
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2261,6 +2617,24 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "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-use-callback-ref": {
|
||||
"version": "1.1.0",
|
||||
"license": "MIT",
|
||||
@@ -3164,12 +3538,20 @@
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.17.16",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.19.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
"version": "6.4.17",
|
||||
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.17.tgz",
|
||||
"integrity": "sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.14",
|
||||
"devOptional": true,
|
||||
@@ -4154,11 +4536,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-kit": {
|
||||
"version": "0.24.2",
|
||||
"version": "0.30.4",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-kit/-/drizzle-kit-0.30.4.tgz",
|
||||
"integrity": "sha512-B2oJN5UkvwwNHscPWXDG5KqAixu7AUzZ3qbe++KU9SsQ+cZWR4DXEPYcvWplyFAno0dhRJECNEhNxiDmFaPGyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@drizzle-team/brocli": "^0.10.1",
|
||||
"@drizzle-team/brocli": "^0.10.2",
|
||||
"@esbuild-kit/esm-loader": "^2.5.5",
|
||||
"esbuild": "^0.19.7",
|
||||
"esbuild-register": "^3.5.0"
|
||||
@@ -4168,14 +4552,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/drizzle-orm": {
|
||||
"version": "0.33.0",
|
||||
"version": "0.39.3",
|
||||
"resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.39.3.tgz",
|
||||
"integrity": "sha512-EZ8ZpYvDIvKU9C56JYLOmUskazhad+uXZCTCRN4OnRMsL+xAJ05dv1eCpAG5xzhsm1hqiuC5kAZUCS924u2DTw==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"@aws-sdk/client-rds-data": ">=3",
|
||||
"@cloudflare/workers-types": ">=3",
|
||||
"@electric-sql/pglite": ">=0.1.1",
|
||||
"@libsql/client": "*",
|
||||
"@neondatabase/serverless": ">=0.1",
|
||||
"@cloudflare/workers-types": ">=4",
|
||||
"@electric-sql/pglite": ">=0.2.0",
|
||||
"@libsql/client": ">=0.10.0",
|
||||
"@libsql/client-wasm": ">=0.10.0",
|
||||
"@neondatabase/serverless": ">=0.10.0",
|
||||
"@op-engineering/op-sqlite": ">=2",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@planetscale/database": ">=1",
|
||||
@@ -4183,19 +4570,17 @@
|
||||
"@tidbcloud/serverless": "*",
|
||||
"@types/better-sqlite3": "*",
|
||||
"@types/pg": "*",
|
||||
"@types/react": ">=18",
|
||||
"@types/sql.js": "*",
|
||||
"@vercel/postgres": ">=0.8.0",
|
||||
"@xata.io/client": "*",
|
||||
"better-sqlite3": ">=7",
|
||||
"bun-types": "*",
|
||||
"expo-sqlite": ">=13.2.0",
|
||||
"expo-sqlite": ">=14.0.0",
|
||||
"knex": "*",
|
||||
"kysely": "*",
|
||||
"mysql2": ">=2",
|
||||
"pg": ">=8",
|
||||
"postgres": ">=3",
|
||||
"react": ">=18",
|
||||
"sql.js": ">=1",
|
||||
"sqlite3": ">=5"
|
||||
},
|
||||
@@ -4212,6 +4597,9 @@
|
||||
"@libsql/client": {
|
||||
"optional": true
|
||||
},
|
||||
"@libsql/client-wasm": {
|
||||
"optional": true
|
||||
},
|
||||
"@neondatabase/serverless": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -4236,9 +4624,6 @@
|
||||
"@types/pg": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/sql.js": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -4275,9 +4660,6 @@
|
||||
"prisma": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"sql.js": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -6180,10 +6562,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.1.6",
|
||||
"version": "15.1.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.1.7.tgz",
|
||||
"integrity": "sha512-GNeINPGS9c6OZKCvKypbL8GTsT5GhWPp4DM0fzkXJuXMilOO2EeFxuAY6JZbtk6XIl6Ws10ag3xRINDjSO5+wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.1.6",
|
||||
"@next/env": "15.1.7",
|
||||
"@swc/counter": "0.1.3",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"busboy": "1.6.0",
|
||||
@@ -6198,14 +6582,14 @@
|
||||
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@next/swc-darwin-arm64": "15.1.6",
|
||||
"@next/swc-darwin-x64": "15.1.6",
|
||||
"@next/swc-linux-arm64-gnu": "15.1.6",
|
||||
"@next/swc-linux-arm64-musl": "15.1.6",
|
||||
"@next/swc-linux-x64-gnu": "15.1.6",
|
||||
"@next/swc-linux-x64-musl": "15.1.6",
|
||||
"@next/swc-win32-arm64-msvc": "15.1.6",
|
||||
"@next/swc-win32-x64-msvc": "15.1.6",
|
||||
"@next/swc-darwin-arm64": "15.1.7",
|
||||
"@next/swc-darwin-x64": "15.1.7",
|
||||
"@next/swc-linux-arm64-gnu": "15.1.7",
|
||||
"@next/swc-linux-arm64-musl": "15.1.7",
|
||||
"@next/swc-linux-x64-gnu": "15.1.7",
|
||||
"@next/swc-linux-x64-musl": "15.1.7",
|
||||
"@next/swc-win32-arm64-msvc": "15.1.7",
|
||||
"@next/swc-win32-x64-msvc": "15.1.7",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -6287,6 +6671,15 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.0.tgz",
|
||||
"integrity": "sha512-SQ3wZCExjeSatLE/HBaXS5vqUOQk6GtBdIIKxiFdmm01mOQZX/POJkO3SUX1wDiYcwUOJwT23scFSC9fY2H8IA==",
|
||||
"license": "MIT-0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/normalize-path": {
|
||||
"version": "3.0.0",
|
||||
"license": "MIT",
|
||||
@@ -8042,7 +8435,6 @@
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.19.8",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/uri-js": {
|
||||
|
||||
13
package.json
13
package.json
@@ -28,6 +28,7 @@
|
||||
"@aws-sdk/lib-storage": "^3.735.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.735.0",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.5",
|
||||
@@ -37,24 +38,28 @@
|
||||
"@radix-ui/react-select": "^2.1.5",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slider": "^1.2.2",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-switch": "^1.1.3",
|
||||
"@radix-ui/react-tabs": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.1.7",
|
||||
"@t3-oss/env-nextjs": "^0.10.1",
|
||||
"@tanstack/react-query": "^5.50.0",
|
||||
"@trpc/client": "^11.0.0-rc.446",
|
||||
"@trpc/react-query": "^11.0.0-rc.446",
|
||||
"@trpc/server": "^11.0.0-rc.446",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.0.0",
|
||||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"drizzle-orm": "^0.39.3",
|
||||
"framer-motion": "^12.0.6",
|
||||
"geist": "^1.3.1",
|
||||
"lucide-react": "^0.474.0",
|
||||
"next": "^15.0.1",
|
||||
"next": "^15.1.7",
|
||||
"next-auth": "^4.24.11",
|
||||
"nodemailer": "^6.10.0",
|
||||
"postgres": "^3.4.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
@@ -75,7 +80,7 @@
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"drizzle-kit": "^0.24.0",
|
||||
"drizzle-kit": "^0.30.4",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-next": "^15.0.1",
|
||||
"eslint-plugin-drizzle": "^0.2.3",
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useToast } from "~/components/ui/use-toast";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
const formSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1).max(256).optional(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export function SignUpForm() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const createUser = api.user.create.useMutation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
await createUser.mutateAsync(data);
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="John Doe"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db } from "~/server/db";
|
||||
import { users } from "~/server/db/schema";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const registerSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
@@ -45,13 +46,14 @@ export async function POST(req: Request) {
|
||||
const hashedPassword = await hash(password, 10);
|
||||
|
||||
await db.insert(users).values({
|
||||
id: randomUUID(),
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password: hashedPassword,
|
||||
});
|
||||
|
||||
return NextResponse.redirect(new URL("/login", req.url));
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -1,67 +1,77 @@
|
||||
import { type Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SignInForm } from "~/app/_components/auth/sign-in-form";
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { SignInForm } from "~/components/auth/sign-in-form";
|
||||
import { Logo } from "~/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign In",
|
||||
title: "Sign In | HRIStudio",
|
||||
description: "Sign in to your account",
|
||||
};
|
||||
|
||||
export default function SignInPage() {
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const session = await getServerAuthSession();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const error = params?.error ? String(params.error) : null;
|
||||
const showError = error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<div className="relative z-20 flex items-center text-lg font-medium">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-6 w-6"
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Welcome back
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Sign in to your account to continue
|
||||
</CardDescription>
|
||||
</div>
|
||||
<SignInForm error={showError} />
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/signup${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||
</svg>
|
||||
HRI Studio
|
||||
</div>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“HRI Studio has revolutionized how we conduct human-robot interaction studies.”
|
||||
</p>
|
||||
<footer className="text-sm">Sofia Dewar</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:p-8">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email to sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
<SignInForm />
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"hover:bg-transparent hover:underline",
|
||||
"px-0"
|
||||
)}
|
||||
>
|
||||
Don't have an account? Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
Sign up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,67 +1,77 @@
|
||||
import { type Metadata } from "next";
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { SignUpForm } from "~/app/_components/auth/sign-up-form";
|
||||
import { buttonVariants } from "~/components/ui/button";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { SignUpForm } from "~/components/auth/sign-up-form";
|
||||
import { Logo } from "~/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Sign Up",
|
||||
title: "Sign Up | HRIStudio",
|
||||
description: "Create a new account",
|
||||
};
|
||||
|
||||
export default function SignUpPage() {
|
||||
export default async function SignUpPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const session = await getServerAuthSession();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const error = params?.error ? String(params.error) : null;
|
||||
const showError = error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
|
||||
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
|
||||
<div className="absolute inset-0 bg-zinc-900" />
|
||||
<div className="relative z-20 flex items-center text-lg font-medium">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="mr-2 h-6 w-6"
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Get started with HRIStudio
|
||||
</CardDescription>
|
||||
</div>
|
||||
<SignUpForm error={showError} />
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href={`/auth/signin${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
|
||||
className="underline underline-offset-4 hover:text-primary"
|
||||
>
|
||||
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
|
||||
</svg>
|
||||
HRI Studio
|
||||
</div>
|
||||
<div className="relative z-20 mt-auto">
|
||||
<blockquote className="space-y-2">
|
||||
<p className="text-lg">
|
||||
“HRI Studio has revolutionized how we conduct human-robot interaction studies.”
|
||||
</p>
|
||||
<footer className="text-sm">Sofia Dewar</footer>
|
||||
</blockquote>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lg:p-8">
|
||||
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
|
||||
<div className="flex flex-col space-y-2 text-center">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Enter your email below to create your account
|
||||
</p>
|
||||
</div>
|
||||
<SignUpForm />
|
||||
<p className="px-8 text-center text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className={cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"hover:bg-transparent hover:underline",
|
||||
"px-0"
|
||||
)}
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
Sign in
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { api } from "~/trpc/react"
|
||||
|
||||
import { AppSidebar } from "~/components/navigation/app-sidebar"
|
||||
import { Header } from "~/components/navigation/header"
|
||||
@@ -18,14 +19,29 @@ export default function DashboardLayout({
|
||||
const { data: session, status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
// Get user's studies
|
||||
const { data: studies, isLoading: isLoadingStudies } = api.study.getMyStudies.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: status === "authenticated",
|
||||
}
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") {
|
||||
router.replace("/login")
|
||||
router.replace("/auth/signin")
|
||||
}
|
||||
}, [status, router])
|
||||
|
||||
useEffect(() => {
|
||||
// Only redirect if we've loaded studies and user has none
|
||||
if (!isLoadingStudies && studies && studies.length === 0) {
|
||||
router.replace("/onboarding")
|
||||
}
|
||||
}, [studies, isLoadingStudies, router])
|
||||
|
||||
// Show nothing while loading
|
||||
if (status === "loading") {
|
||||
if (status === "loading" || isLoadingStudies) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -34,6 +50,11 @@ export default function DashboardLayout({
|
||||
return null
|
||||
}
|
||||
|
||||
// Show nothing if no studies (will redirect to onboarding)
|
||||
if (studies && studies.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<StudyProvider>
|
||||
|
||||
62
src/app/dashboard/studies/[id]/delete-study-button.tsx
Normal file
62
src/app/dashboard/studies/[id]/delete-study-button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface DeleteStudyButtonProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export default function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push("/studies");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the study
|
||||
and all associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteStudy({ id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
65
src/app/dashboard/studies/[id]/edit/page.tsx
Normal file
65
src/app/dashboard/studies/[id]/edit/page.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { use } from "react";
|
||||
|
||||
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter();
|
||||
const resolvedParams = use(params);
|
||||
const id = Number(resolvedParams.id);
|
||||
|
||||
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery(
|
||||
{ id }
|
||||
);
|
||||
|
||||
const { mutate: updateStudy, isPending: isUpdating } = api.study.update.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push(`/dashboard/studies/${id}`);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: StudyFormValues) {
|
||||
updateStudy({ id, ...data });
|
||||
}
|
||||
|
||||
if (isLoadingStudy) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!study) {
|
||||
return <div>Study not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edit Study"
|
||||
description="Update study details"
|
||||
/>
|
||||
<PageContent className="max-w-2xl">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Study Details</CardTitle>
|
||||
<CardDescription>
|
||||
Update the information for your study.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StudyForm
|
||||
defaultValues={{ title: study.title, description: study.description ?? "" }}
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isUpdating}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
86
src/app/dashboard/studies/[id]/page.tsx
Normal file
86
src/app/dashboard/studies/[id]/page.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { Pencil as PencilIcon } from "lucide-react";
|
||||
import { use } from "react";
|
||||
import { StudyOverview } from "~/components/studies/study-overview";
|
||||
import { StudyParticipants } from "~/components/studies/study-participants";
|
||||
import { StudyMembers } from "~/components/studies/study-members";
|
||||
import { StudyMetadata } from "~/components/studies/study-metadata";
|
||||
import { StudyActivity } from "~/components/studies/study-activity";
|
||||
|
||||
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const resolvedParams = use(params);
|
||||
const id = Number(resolvedParams.id);
|
||||
const activeTab = searchParams.get("tab") ?? "overview";
|
||||
|
||||
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
|
||||
|
||||
if (isLoadingStudy) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!study) {
|
||||
return <div>Study not found</div>;
|
||||
}
|
||||
|
||||
const canEdit = study.role === "admin";
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={study.title}
|
||||
description={study.description ?? "No description provided"}
|
||||
>
|
||||
{canEdit && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/studies/${id}/edit`)}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 mr-2" />
|
||||
Edit Study
|
||||
</Button>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<Tabs defaultValue={activeTab} className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="participants">Participants</TabsTrigger>
|
||||
<TabsTrigger value="members">Members</TabsTrigger>
|
||||
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview" className="space-y-4">
|
||||
<StudyOverview study={study} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="participants" className="space-y-4">
|
||||
<StudyParticipants studyId={id} role={study.role} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="members" className="space-y-4">
|
||||
<StudyMembers studyId={id} role={study.role} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="metadata" className="space-y-4">
|
||||
<StudyMetadata studyId={id} role={study.role} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="activity" className="space-y-4">
|
||||
<StudyActivity studyId={id} role={study.role} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
|
||||
import { use } from "react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
|
||||
export default function EditParticipantPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const resolvedParams = use(params);
|
||||
const studyId = Number(resolvedParams.id);
|
||||
const participantId = Number(resolvedParams.participantId);
|
||||
|
||||
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
|
||||
|
||||
const { mutate: updateParticipant, isPending: isUpdating } = api.participant.update.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Participant updated successfully",
|
||||
});
|
||||
router.push(`/dashboard/studies/${studyId}/participants/${participantId}`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: ParticipantFormValues) {
|
||||
updateParticipant({
|
||||
id: participantId,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!study || !participant) {
|
||||
return <div>Not found</div>;
|
||||
}
|
||||
|
||||
// Check if user has permission to edit participants
|
||||
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(study.role.toLowerCase());
|
||||
|
||||
if (!canManageParticipants) {
|
||||
return <div>You do not have permission to edit participants in this study.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Edit Participant"
|
||||
description={`Update participant details for ${study.title}`}
|
||||
/>
|
||||
<PageContent>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participant Details</CardTitle>
|
||||
<CardDescription>
|
||||
Update the participant's information. Fields marked with * are required.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ParticipantForm
|
||||
defaultValues={{
|
||||
identifier: participant.identifier ?? "",
|
||||
email: participant.email ?? "",
|
||||
firstName: participant.firstName ?? "",
|
||||
lastName: participant.lastName ?? "",
|
||||
notes: participant.notes ?? "",
|
||||
status: participant.status,
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isUpdating}
|
||||
submitLabel="Save Changes"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Pencil as PencilIcon, Trash as TrashIcon } from "lucide-react";
|
||||
import { use } from "react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
|
||||
export default function ParticipantDetailsPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const resolvedParams = use(params);
|
||||
const studyId = Number(resolvedParams.id);
|
||||
const participantId = Number(resolvedParams.participantId);
|
||||
|
||||
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
|
||||
|
||||
const { mutate: deleteParticipant, isPending: isDeleting } = api.participant.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Participant deleted successfully",
|
||||
});
|
||||
router.push(`/dashboard/studies/${studyId}/participants`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!study || !participant) {
|
||||
return <div>Not found</div>;
|
||||
}
|
||||
|
||||
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(study.role.toLowerCase());
|
||||
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(study.role.toLowerCase());
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Participant Details"
|
||||
description={`View participant details for ${study.title}`}
|
||||
>
|
||||
{canManageParticipants && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/studies/${studyId}/participants/${participantId}/edit`)
|
||||
}
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" disabled={isDeleting}>
|
||||
<TrashIcon className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the participant and all
|
||||
associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteParticipant({ id: participantId })}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
{isDeleting ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<div className="grid gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Basic Information</CardTitle>
|
||||
{!canViewIdentifiableInfo && (
|
||||
<CardDescription className="text-yellow-600">
|
||||
Some information is redacted based on your role.
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Identifier</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{canViewIdentifiableInfo ? participant.identifier || "—" : "REDACTED"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Status</dt>
|
||||
<dd className="mt-1">
|
||||
<Badge variant="secondary">
|
||||
{participant.status}
|
||||
</Badge>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Name</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{canViewIdentifiableInfo
|
||||
? participant.firstName && participant.lastName
|
||||
? `${participant.firstName} ${participant.lastName}`
|
||||
: "—"
|
||||
: "REDACTED"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{canViewIdentifiableInfo ? participant.email || "—" : "REDACTED"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Notes</CardTitle>
|
||||
<CardDescription>Additional information about this participant</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{participant.notes || "No notes available."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
src/app/dashboard/studies/[id]/participants/new/page.tsx
Normal file
102
src/app/dashboard/studies/[id]/participants/new/page.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
|
||||
import { use } from "react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
|
||||
function generateIdentifier(studyId: number, count: number) {
|
||||
// Format: P001, P002, etc. with study prefix
|
||||
const paddedCount = String(count + 1).padStart(3, '0');
|
||||
return `P${paddedCount}`;
|
||||
}
|
||||
|
||||
export default function NewParticipantPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const resolvedParams = use(params);
|
||||
const studyId = Number(resolvedParams.id);
|
||||
|
||||
const { data: study } = api.study.getById.useQuery({ id: studyId });
|
||||
const { data: participantCount = 0 } = api.participant.getCount.useQuery(
|
||||
{ studyId },
|
||||
{ enabled: !!study }
|
||||
);
|
||||
|
||||
const { mutate: createParticipant, isPending: isCreating } = api.participant.create.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Participant added successfully",
|
||||
});
|
||||
router.push(`/dashboard/studies/${studyId}/participants`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: ParticipantFormValues) {
|
||||
createParticipant({
|
||||
studyId,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
if (!study) {
|
||||
return <div>Study not found</div>;
|
||||
}
|
||||
|
||||
// Check if user has permission to add participants
|
||||
const canAddParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(study.role.toLowerCase());
|
||||
|
||||
if (!canAddParticipants) {
|
||||
return <div>You do not have permission to add participants to this study.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Add Participant"
|
||||
description={`Add a new participant to ${study.title}`}
|
||||
/>
|
||||
<PageContent>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Participant Details</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the participant's information. Fields marked with * are required.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ParticipantForm
|
||||
defaultValues={{
|
||||
identifier: generateIdentifier(studyId, participantCount),
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
}}
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isCreating}
|
||||
submitLabel="Add Participant"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
src/app/dashboard/studies/[id]/participants/page.tsx
Normal file
36
src/app/dashboard/studies/[id]/participants/page.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { use } from "react";
|
||||
import { StudyParticipants } from "~/components/studies/study-participants";
|
||||
|
||||
export default function ParticipantsPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const router = useRouter();
|
||||
const resolvedParams = use(params);
|
||||
const studyId = Number(resolvedParams.id);
|
||||
|
||||
const { data: study, isLoading } = api.study.getById.useQuery({ id: studyId });
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!study) {
|
||||
return <div>Study not found</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Participants"
|
||||
description={`Manage participants for ${study.title}`}
|
||||
/>
|
||||
<PageContent>
|
||||
<StudyParticipants studyId={studyId} role={study.role} />
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
src/app/dashboard/studies/new/page.tsx
Normal file
50
src/app/dashboard/studies/new/page.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
|
||||
export default function NewStudyPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
router.push(`/dashboard/studies/${data.id}`);
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(data: StudyFormValues) {
|
||||
createStudy(data);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="New Study"
|
||||
description="Create a new study"
|
||||
/>
|
||||
<PageContent>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Study Details</CardTitle>
|
||||
<CardDescription>
|
||||
Enter the information for your new study.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<StudyForm
|
||||
defaultValues={{ title: "", description: "" }}
|
||||
onSubmit={onSubmit}
|
||||
isSubmitting={isCreating}
|
||||
submitLabel="Create Study"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
69
src/app/dashboard/studies/page.tsx
Normal file
69
src/app/dashboard/studies/page.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { PageHeader } from "~/components/layout/page-header";
|
||||
import { PageContent } from "~/components/layout/page-content";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Plus as PlusIcon } from "lucide-react";
|
||||
|
||||
export default function StudiesPage() {
|
||||
const router = useRouter();
|
||||
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Studies"
|
||||
description="Manage your research studies"
|
||||
>
|
||||
<Button
|
||||
onClick={() => router.push("/dashboard/studies/new")}
|
||||
size="sm"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
New Study
|
||||
</Button>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<div className="grid gap-6">
|
||||
{!studies || studies.length === 0 ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>No Studies</CardTitle>
|
||||
<CardDescription>
|
||||
You haven't created any studies yet. Click the button above to create your first study.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
) : (
|
||||
studies.map((study) => (
|
||||
<Card
|
||||
key={study.id}
|
||||
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||
onClick={() => router.push(`/dashboard/studies/${study.id}`)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{study.title}</CardTitle>
|
||||
<CardDescription>
|
||||
{study.description || "No description provided"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Your role: {study.role}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</PageContent>
|
||||
</>
|
||||
);
|
||||
}
|
||||
328
src/app/invite/page.tsx
Normal file
328
src/app/invite/page.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession, signOut } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import Link from "next/link";
|
||||
import { format } from "date-fns";
|
||||
import { Logo } from "~/components/logo";
|
||||
|
||||
export default function InvitePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data: session, status } = useSession();
|
||||
const { toast } = useToast();
|
||||
|
||||
const token = searchParams.get("token");
|
||||
|
||||
// Don't fetch invitation data until we're authenticated
|
||||
const { data: invitation, isLoading: isLoadingInvitation } = api.study.getInvitation.useQuery(
|
||||
{ token: token! },
|
||||
{
|
||||
enabled: !!token && status === "authenticated",
|
||||
retry: false,
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const { mutate: acceptInvitation, isLoading: isAccepting } = api.study.acceptInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "You have successfully joined the study.",
|
||||
});
|
||||
router.push(`/dashboard/studies/${invitation?.studyId}`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Show loading state for missing token
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Invalid Invitation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
No invitation token provided. Please check your invitation link.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/dashboard">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show authentication required state
|
||||
if (status === "unauthenticated") {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Study Invitation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Sign in or create an account to view and accept this invitation.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<Button asChild variant="default" className="w-full">
|
||||
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||
Sign In
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs uppercase">
|
||||
<span className="bg-background px-2 text-muted-foreground">
|
||||
or
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button asChild variant="outline" className="w-full">
|
||||
<Link href={`/auth/signup?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||
Create Account
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Please wait while we load your invitation.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state for invalid invitation
|
||||
if (!invitation) {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Invalid Invitation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
This invitation link appears to be invalid or has expired. Please request a new invitation.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/dashboard">Return to Dashboard</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Study Invitation
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
You've been invited to join {invitation.study.title} as a {invitation.role}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-muted p-4 space-y-2">
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Study: </span>
|
||||
<span className="text-sm">{invitation.study.title}</span>
|
||||
</div>
|
||||
{invitation.study.description && (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Description: </span>
|
||||
<span className="text-sm">{invitation.study.description}</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Role: </span>
|
||||
<span className="text-sm capitalize">{invitation.role}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Invited by: </span>
|
||||
<span className="text-sm">
|
||||
{invitation.creator.firstName} {invitation.creator.lastName}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-muted-foreground">Expires: </span>
|
||||
<span className="text-sm">{format(new Date(invitation.expiresAt), "PPp")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{session.user.email === invitation.email ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => acceptInvitation({ token })}
|
||||
disabled={isAccepting}
|
||||
>
|
||||
{isAccepting ? "Accepting..." : "Accept Invitation"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This invitation was sent to {invitation.email}, but you're signed in with a different
|
||||
email address ({session.user.email}).
|
||||
</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button asChild variant="default" className="w-full">
|
||||
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
|
||||
Sign in with a different account
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => signOut({ callbackUrl: `/invite?token=${token}` })}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import "~/styles/globals.css";
|
||||
|
||||
import { Inter } from "next/font/google";
|
||||
import { GeistSans } from 'geist/font/sans';
|
||||
import { headers } from "next/headers";
|
||||
|
||||
@@ -9,12 +8,6 @@ import { cn } from "~/lib/utils";
|
||||
import { Providers } from "~/components/providers";
|
||||
import DatabaseCheck from "~/components/db-check";
|
||||
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
variable: "--font-sans",
|
||||
});
|
||||
|
||||
|
||||
export const metadata = {
|
||||
title: "HRIStudio",
|
||||
description: "A platform for managing human research studies and participant interactions.",
|
||||
@@ -30,7 +23,7 @@ export default function RootLayout({
|
||||
<html lang="en" className="h-full">
|
||||
<body className={cn(
|
||||
"min-h-screen bg-background font-sans antialiased",
|
||||
inter.variable
|
||||
GeistSans.className
|
||||
)}>
|
||||
<TRPCReactProvider {...{ headers: headers() }}>
|
||||
<Providers>
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import Link from "next/link";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export function LoginForm({ error }: { error: boolean }) {
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const response = await signIn("credentials", {
|
||||
email: formData.get("email"),
|
||||
password: formData.get("password"),
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (!response?.error) {
|
||||
router.push("/dashboard");
|
||||
router.refresh();
|
||||
} else {
|
||||
router.push("/login?error=CredentialsSignin");
|
||||
router.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="animate-in fade-in-50 slide-in-from-bottom-4">
|
||||
{error && (
|
||||
<div className="mb-4 rounded border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||
<p>Invalid email or password</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
className="auth-input"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-muted-foreground transition-colors hover:text-primary"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
className="auth-input"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button className="w-full" type="submit" disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
"Sign In"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/register"
|
||||
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { LoginForm } from "./login-form";
|
||||
import { Logo } from "~/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Login | HRIStudio",
|
||||
description: "Login to your account",
|
||||
};
|
||||
|
||||
export default async function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const session = await getServerAuthSession();
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
const params = await searchParams;
|
||||
const error = params?.error ? String(params.error) : null;
|
||||
const showError = error === "CredentialsSignin";
|
||||
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Welcome back
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Sign in to your account to continue
|
||||
</CardDescription>
|
||||
</div>
|
||||
<LoginForm error={showError} />
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
By signing in, you agree to our{" "}
|
||||
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
537
src/app/onboarding/page.tsx
Normal file
537
src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,537 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import Link from "next/link";
|
||||
import { Logo } from "~/components/logo";
|
||||
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, ArrowRight, Bot, Users, Microscope, Beaker, GitBranch } from "lucide-react";
|
||||
import { LucideIcon } from "lucide-react";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: LucideIcon;
|
||||
content?: React.ReactNode;
|
||||
}
|
||||
|
||||
// Define the onboarding steps
|
||||
const ONBOARDING_STEPS: OnboardingStep[] = [
|
||||
{
|
||||
id: "welcome",
|
||||
title: "Welcome to HRIStudio",
|
||||
description: "Your platform for human-robot interaction research",
|
||||
icon: Bot,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
HRIStudio is a comprehensive platform designed to help researchers:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||
<li>Design and run Wizard-of-Oz experiments</li>
|
||||
<li>Manage research participants and data collection</li>
|
||||
<li>Collaborate with team members in real-time</li>
|
||||
<li>Analyze and export research data</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "roles",
|
||||
title: "Understanding Roles",
|
||||
description: "Different roles for different responsibilities",
|
||||
icon: Users,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
HRIStudio supports various team roles:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||
<li><span className="font-medium text-foreground">Owner & Admin:</span> Manage study settings and team</li>
|
||||
<li><span className="font-medium text-foreground">Principal Investigator:</span> Oversee research design</li>
|
||||
<li><span className="font-medium text-foreground">Wizard:</span> Control robot behavior during experiments</li>
|
||||
<li><span className="font-medium text-foreground">Researcher:</span> Analyze data and results</li>
|
||||
<li><span className="font-medium text-foreground">Observer:</span> View and annotate sessions</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "studies",
|
||||
title: "Managing Studies",
|
||||
description: "Organize your research effectively",
|
||||
icon: Microscope,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Studies are the core of HRIStudio:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||
<li>Create multiple studies for different research projects</li>
|
||||
<li>Invite team members with specific roles</li>
|
||||
<li>Manage participant recruitment and data</li>
|
||||
<li>Configure experiment protocols and settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "hierarchy",
|
||||
title: "Study Structure",
|
||||
description: "Understanding the experiment hierarchy",
|
||||
icon: GitBranch,
|
||||
content: (
|
||||
<div className="space-y-6">
|
||||
<div className="relative mx-auto w-full max-w-[400px]">
|
||||
{/* Study Level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||
>
|
||||
<div className="font-medium">Study</div>
|
||||
<div className="text-xs text-muted-foreground">Research Project</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Connecting Line */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
className="absolute left-1/2 top-[60px] h-8 w-px -translate-x-1/2 bg-border"
|
||||
/>
|
||||
|
||||
{/* Experiments Level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||
>
|
||||
<div className="font-medium">Experiments</div>
|
||||
<div className="text-xs text-muted-foreground">Study Protocols</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Connecting Line */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="absolute left-1/2 top-[140px] h-8 w-px -translate-x-1/2 bg-border"
|
||||
/>
|
||||
|
||||
{/* Trials Level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||
>
|
||||
<div className="font-medium">Trials</div>
|
||||
<div className="text-xs text-muted-foreground">Individual Sessions</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Connecting Line */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
className="absolute left-1/2 top-[220px] h-8 w-px -translate-x-1/2 bg-border"
|
||||
/>
|
||||
|
||||
{/* Steps Level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||
>
|
||||
<div className="font-medium">Steps</div>
|
||||
<div className="text-xs text-muted-foreground">Trial Procedures</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Connecting Line */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.8 }}
|
||||
className="absolute left-1/2 bottom-[60px] h-8 w-px -translate-x-1/2 bg-border"
|
||||
/>
|
||||
|
||||
{/* Actions Level */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.9 }}
|
||||
className="mx-auto w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
|
||||
>
|
||||
<div className="font-medium">Actions</div>
|
||||
<div className="text-xs text-muted-foreground">Individual Operations</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p>The experiment structure flows from top to bottom:</p>
|
||||
<ul className="mt-2 list-inside list-disc space-y-1">
|
||||
<li><span className="font-medium text-foreground">Study:</span> Contains experiments and team members</li>
|
||||
<li><span className="font-medium text-foreground">Experiments:</span> Define reusable protocols</li>
|
||||
<li><span className="font-medium text-foreground">Trials:</span> Individual sessions with participants</li>
|
||||
<li><span className="font-medium text-foreground">Steps:</span> Ordered procedures within a trial</li>
|
||||
<li><span className="font-medium text-foreground">Actions:</span> Specific operations (movement, speech, etc.)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "experiments",
|
||||
title: "Running Experiments",
|
||||
description: "Conduct Wizard-of-Oz studies seamlessly",
|
||||
icon: Beaker,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<p className="text-muted-foreground">
|
||||
Design and execute experiments with ease:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
|
||||
<li>Create reusable experiment templates</li>
|
||||
<li>Define robot behaviors and interactions</li>
|
||||
<li>Record and annotate sessions in real-time</li>
|
||||
<li>Collect and analyze participant data</li>
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "setup",
|
||||
title: "Let's Get Started",
|
||||
description: "Create your first study or join an existing one",
|
||||
icon: Bot,
|
||||
},
|
||||
];
|
||||
|
||||
// Update slideVariants
|
||||
const slideVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 50 : -50,
|
||||
opacity: 0
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 50 : -50,
|
||||
opacity: 0
|
||||
})
|
||||
};
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { data: session, status } = useSession();
|
||||
const { toast } = useToast();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [direction, setDirection] = useState(0);
|
||||
|
||||
// Get invitation token if it exists
|
||||
const token = searchParams.get("token");
|
||||
|
||||
// Fetch invitation if token exists
|
||||
const { data: invitation } = api.study.getInvitation.useQuery(
|
||||
{ token: token! },
|
||||
{
|
||||
enabled: !!token && status === "authenticated",
|
||||
retry: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Mutation for accepting invitation
|
||||
const { mutate: acceptInvitation, isPending: isAccepting } = api.study.acceptInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "You have successfully joined the study.",
|
||||
});
|
||||
router.push(`/dashboard/studies/${invitation?.studyId}`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Mutation for creating a new study
|
||||
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Your study has been created successfully.",
|
||||
});
|
||||
router.push(`/dashboard/studies/${data.id}`);
|
||||
router.refresh();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Handle study creation
|
||||
function onCreateStudy(data: StudyFormValues) {
|
||||
createStudy(data);
|
||||
}
|
||||
|
||||
// Navigation functions
|
||||
const nextStep = () => {
|
||||
if (currentStep < ONBOARDING_STEPS.length - 1) {
|
||||
setDirection(1);
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1);
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// Ensure currentStep is within bounds
|
||||
const safeStep = Math.min(Math.max(0, currentStep), ONBOARDING_STEPS.length - 1);
|
||||
const currentStepData = ONBOARDING_STEPS[safeStep]!;
|
||||
const Icon = currentStepData.icon;
|
||||
|
||||
// Show loading state while checking authentication
|
||||
if (status === "loading") {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Loading...
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Please wait while we set up your account.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirect to sign in if not authenticated
|
||||
if (status === "unauthenticated") {
|
||||
router.push("/auth/signin");
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user has an invitation and we're on the final step
|
||||
if (token && invitation && safeStep === ONBOARDING_STEPS.length - 1) {
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Join {invitation.study.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
You've been invited to join as a {invitation.role}.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{session?.user.email === invitation.email ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => acceptInvitation({ token })}
|
||||
disabled={isAccepting}
|
||||
>
|
||||
{isAccepting ? "Joining Study..." : "Join Study"}
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This invitation was sent to {invitation.email}, but you're signed in with a different
|
||||
email address ({session?.user.email}).
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => router.push("/auth/signin")}
|
||||
>
|
||||
Sign in with correct account
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[1000px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="relative p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<motion.div
|
||||
className="mb-8 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<Icon className="h-6 w-6 text-primary" />
|
||||
</motion.div>
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
{currentStepData.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
{currentStepData.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
|
||||
<div className="relative h-[280px]">
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={currentStepData.id}
|
||||
custom={direction}
|
||||
variants={slideVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { type: "spring", stiffness: 300, damping: 30 },
|
||||
opacity: { duration: 0.2 }
|
||||
}}
|
||||
className="absolute inset-0"
|
||||
>
|
||||
<div className="relative h-full">
|
||||
<div className="h-full overflow-y-auto pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/30">
|
||||
{safeStep === ONBOARDING_STEPS.length - 1 ? (
|
||||
<StudyForm
|
||||
defaultValues={{ title: "", description: "" }}
|
||||
onSubmit={onCreateStudy}
|
||||
isSubmitting={isCreating}
|
||||
submitLabel="Create Study"
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{currentStepData.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-between pt-4">
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={prevStep}
|
||||
disabled={safeStep === 0}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back
|
||||
</Button>
|
||||
</motion.div>
|
||||
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
|
||||
<Button onClick={nextStep}>
|
||||
Next
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute bottom-8 left-8 right-8">
|
||||
<div className="flex justify-between gap-2">
|
||||
{ONBOARDING_STEPS.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`h-1 flex-1 rounded-full transition-colors ${
|
||||
index <= safeStep ? "bg-primary" : "bg-border"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,10 +19,10 @@ export default async function Home() {
|
||||
{!session && (
|
||||
<>
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/login">Sign In</Link>
|
||||
<Link href="/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
<Button asChild>
|
||||
<Link href="/register">Sign Up</Link>
|
||||
<Link href="/auth/signup">Sign Up</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -47,7 +47,7 @@ export default async function Home() {
|
||||
<div className="mt-8 flex flex-col sm:flex-row gap-4">
|
||||
{!session ? (
|
||||
<Button size="lg" className="w-full sm:w-auto" asChild>
|
||||
<Link href="/register">Get Started</Link>
|
||||
<Link href="/auth/signup">Get Started</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="lg" className="w-full sm:w-auto" asChild>
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getServerAuthSession } from "~/server/auth";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardTitle
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Logo } from "~/components/logo";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Register | HRIStudio",
|
||||
description: "Create a new account",
|
||||
};
|
||||
|
||||
export default async function RegisterPage() {
|
||||
const session = await getServerAuthSession();
|
||||
|
||||
if (session) {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
|
||||
<Logo
|
||||
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
|
||||
iconClassName="text-primary"
|
||||
/>
|
||||
<div className="w-full max-w-[800px] px-4 py-8">
|
||||
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
|
||||
<CardContent className="grid p-0 md:grid-cols-2">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-6 space-y-2">
|
||||
<CardTitle className="text-2xl font-bold tracking-tight">
|
||||
Create an account
|
||||
</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
Enter your details to get started
|
||||
</CardDescription>
|
||||
</div>
|
||||
<form action="/api/auth/register" method="POST" className="animate-in fade-in-50 slide-in-from-bottom-4">
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="firstName">First Name</Label>
|
||||
<Input
|
||||
id="firstName"
|
||||
name="firstName"
|
||||
placeholder="John"
|
||||
autoComplete="given-name"
|
||||
className="auth-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="lastName">Last Name</Label>
|
||||
<Input
|
||||
id="lastName"
|
||||
name="lastName"
|
||||
placeholder="Doe"
|
||||
autoComplete="family-name"
|
||||
className="auth-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
autoCorrect="off"
|
||||
className="auth-input"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
name="password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
className="auth-input"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Must be at least 8 characters long
|
||||
</p>
|
||||
</div>
|
||||
<Button className="w-full" type="submit">
|
||||
Create account
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 text-center text-sm text-muted-foreground">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/login"
|
||||
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="relative hidden h-full md:block">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Logo
|
||||
className="pointer-events-none"
|
||||
iconClassName="h-32 w-32 mr-0 text-primary/40"
|
||||
textClassName="sr-only"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<p className="mt-6 text-center text-sm text-muted-foreground">
|
||||
By creating an account, you agree to our{" "}
|
||||
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
|
||||
Terms of Service
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
export default async function StudyPage({ params }: { params: { id: string } }) {
|
||||
const study = await db.query.studies.findFirst({
|
||||
where: (studies, { eq }) => eq(studies.id, params.id),
|
||||
with: { experiments: true }
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<StudyHeader study={study} />
|
||||
<Suspense fallback={<ExperimentListSkeleton />}>
|
||||
<ExperimentList studyId={params.id} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -16,29 +16,46 @@ import {
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useToast } from "~/components/ui/use-toast";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import React from "react";
|
||||
|
||||
const formSchema = z.object({
|
||||
const signInSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
type SignInValues = z.infer<typeof signInSchema>;
|
||||
|
||||
export function SignInForm() {
|
||||
interface SignInFormProps {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function SignInForm({ error }: SignInFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
// Show error toast if credentials are invalid
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Invalid email or password",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
const form = useForm<SignInValues>({
|
||||
resolver: zodResolver(signInSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
email: searchParams.get("email") ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
async function onSubmit(data: SignInValues) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
@@ -50,20 +67,21 @@ export function SignInForm() {
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Invalid email or password",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
|
||||
router.push(callbackUrl);
|
||||
router.refresh();
|
||||
router.push("/");
|
||||
} catch (error) {
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -91,6 +109,7 @@ export function SignInForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
@@ -109,6 +128,7 @@ export function SignInForm() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Signing in..." : "Sign in"}
|
||||
</Button>
|
||||
210
src/components/auth/sign-up-form.tsx
Normal file
210
src/components/auth/sign-up-form.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import React from "react";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
});
|
||||
|
||||
type SignUpValues = z.infer<typeof signUpSchema>;
|
||||
|
||||
interface SignUpFormProps {
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function SignUpForm({ error }: SignUpFormProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { toast } = useToast();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// Show error toast if credentials are invalid
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
const form = useForm<SignUpValues>({
|
||||
resolver: zodResolver(signUpSchema),
|
||||
defaultValues: {
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
email: searchParams.get("email") ?? "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
async function onSubmit(data: SignUpValues) {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("firstName", data.firstName);
|
||||
formData.append("lastName", data.lastName);
|
||||
formData.append("email", data.email);
|
||||
formData.append("password", data.password);
|
||||
|
||||
const response = await fetch("/api/auth/register", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.error ?? "Something went wrong");
|
||||
}
|
||||
|
||||
const result = await signIn("credentials", {
|
||||
redirect: false,
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the invitation token if it exists
|
||||
const token = searchParams.get("token");
|
||||
|
||||
// Redirect to onboarding with token if it exists
|
||||
const onboardingUrl = token
|
||||
? `/onboarding?token=${token}`
|
||||
: "/onboarding";
|
||||
|
||||
router.push(onboardingUrl);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Something went wrong. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="John"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Doe"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="name@example.com"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
{...field}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? "Creating account..." : "Create account"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,14 @@ import {
|
||||
Beaker,
|
||||
Home,
|
||||
Settings2,
|
||||
User
|
||||
User,
|
||||
Microscope,
|
||||
Users,
|
||||
Plus
|
||||
} from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useStudy } from "~/components/providers/study-provider"
|
||||
|
||||
import { StudySwitcher } from "~/components/auth/study-switcher"
|
||||
import {
|
||||
@@ -20,18 +24,23 @@ import {
|
||||
import { NavMain } from "~/components/navigation/nav-main"
|
||||
import { NavUser } from "~/components/navigation/nav-user"
|
||||
|
||||
const data = {
|
||||
navMain: [
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { data: session } = useSession()
|
||||
const { activeStudy } = useStudy()
|
||||
|
||||
if (!session) return null
|
||||
|
||||
// Base navigation items that are always shown
|
||||
const baseNavItems = [
|
||||
{
|
||||
title: "Overview",
|
||||
url: "/dashboard",
|
||||
icon: Home,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
title: "Studies",
|
||||
url: "/dashboard/studies",
|
||||
icon: Beaker,
|
||||
icon: Microscope,
|
||||
items: [
|
||||
{
|
||||
title: "All Studies",
|
||||
@@ -43,6 +52,33 @@ const data = {
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// Study-specific navigation items that are only shown when a study is active
|
||||
const studyNavItems = activeStudy
|
||||
? [
|
||||
{
|
||||
title: "Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
icon: Users,
|
||||
items: [
|
||||
{
|
||||
title: "All Participants",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants`,
|
||||
},
|
||||
{
|
||||
title: "Add Participant",
|
||||
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
|
||||
// Only show if user is admin
|
||||
hidden: activeStudy.role !== "ADMIN",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
// Settings navigation items
|
||||
const settingsNavItems = [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/dashboard/settings",
|
||||
@@ -63,12 +99,9 @@ const data = {
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { data: session } = useSession()
|
||||
if (!session) return null
|
||||
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
@@ -81,7 +114,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
<StudySwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain items={data.navMain} />
|
||||
<NavMain items={navItems} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<NavUser />
|
||||
|
||||
@@ -31,6 +31,39 @@ export function BreadcrumbNav() {
|
||||
label: "Create Study",
|
||||
current: true,
|
||||
})
|
||||
} else if (paths[2]) {
|
||||
items.push({
|
||||
label: "Study Details",
|
||||
href: `/dashboard/studies/${paths[2]}`,
|
||||
current: paths.length === 3,
|
||||
})
|
||||
|
||||
if (paths[3] === "participants") {
|
||||
items.push({
|
||||
label: "Participants",
|
||||
href: `/dashboard/studies/${paths[2]}/participants`,
|
||||
current: paths.length === 4,
|
||||
})
|
||||
|
||||
if (paths[4] === "new") {
|
||||
items.push({
|
||||
label: "Add Participant",
|
||||
current: true,
|
||||
})
|
||||
} else if (paths[4]) {
|
||||
items.push({
|
||||
label: "Participant Details",
|
||||
current: paths.length === 5,
|
||||
})
|
||||
|
||||
if (paths[5] === "edit") {
|
||||
items.push({
|
||||
label: "Edit",
|
||||
current: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,11 +74,11 @@ export function BreadcrumbNav() {
|
||||
|
||||
return (
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol className="flex items-center gap-2">
|
||||
<ol className="flex items-center">
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<li key={item.label} className="flex items-center">
|
||||
{index > 0 && (
|
||||
<span role="presentation" aria-hidden="true" className="mx-2 text-muted-foreground">
|
||||
<span role="presentation" aria-hidden="true" className="mx-1 text-muted-foreground">
|
||||
/
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -63,7 +63,7 @@ export function NavUser() {
|
||||
<div className="relative size-full overflow-hidden rounded-lg">
|
||||
<Image
|
||||
src={session.user.image}
|
||||
alt={session.user.name ?? "User"}
|
||||
alt={session.user.firstName ?? "User"}
|
||||
fill
|
||||
sizes="32px"
|
||||
className="object-cover"
|
||||
|
||||
192
src/components/participants/participant-form.tsx
Normal file
192
src/components/participants/participant-form.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
|
||||
const participantFormSchema = z.object({
|
||||
identifier: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
status: z.enum(["active", "inactive", "completed", "withdrawn"]).default("active"),
|
||||
});
|
||||
|
||||
export type ParticipantFormValues = z.infer<typeof participantFormSchema>;
|
||||
|
||||
interface ParticipantFormProps {
|
||||
defaultValues?: Partial<ParticipantFormValues>;
|
||||
onSubmit: (data: ParticipantFormValues) => void;
|
||||
isSubmitting?: boolean;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function ParticipantForm({
|
||||
defaultValues = {
|
||||
identifier: "",
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
notes: "",
|
||||
status: "active",
|
||||
},
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = "Save",
|
||||
}: ParticipantFormProps) {
|
||||
const form = useForm<ParticipantFormValues>({
|
||||
resolver: zodResolver(participantFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Identifier</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Enter participant identifier"
|
||||
{...field}
|
||||
readOnly
|
||||
className="bg-muted"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Auto-generated unique identifier
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="status"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Status</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select participant status" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
<SelectItem value="completed">Completed</SelectItem>
|
||||
<SelectItem value="withdrawn">Withdrawn</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>First Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter first name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Last Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter last name" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="email"
|
||||
placeholder="Enter participant email"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="notes"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Notes (Optional)</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter any notes about this participant"
|
||||
className="h-20"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
131
src/components/studies/create-study-form.tsx
Normal file
131
src/components/studies/create-study-form.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useSession } from "next-auth/react";
|
||||
|
||||
const createStudySchema = z.object({
|
||||
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
type FormData = z.infer<typeof createStudySchema>;
|
||||
|
||||
export function CreateStudyForm() {
|
||||
const router = useRouter();
|
||||
const { toast } = useToast();
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(createStudySchema),
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
|
||||
const createStudy = api.study.create.useMutation({
|
||||
onSuccess: (study) => {
|
||||
toast({
|
||||
title: "Study created",
|
||||
description: "Your study has been created successfully.",
|
||||
});
|
||||
router.push(`/dashboard/studies/${study.id}`);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
if (status !== "authenticated") {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "You must be logged in to create a study.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
createStudy.mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter study title" {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A descriptive name for your study.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter study description"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
A brief description of your study and its objectives.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createStudy.isLoading || status !== "authenticated"}
|
||||
>
|
||||
{createStudy.isLoading ? "Creating..." : "Create Study"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
62
src/components/studies/delete-study-button.tsx
Normal file
62
src/components/studies/delete-study-button.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
interface DeleteStudyButtonProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
router.push("/studies");
|
||||
router.refresh();
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the study
|
||||
and all associated data.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteStudy({ id })}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Deleting..." : "Delete"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
79
src/components/studies/study-activity.tsx
Normal file
79
src/components/studies/study-activity.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { format } from "date-fns";
|
||||
import { Activity, User, UserPlus, Settings, FileEdit } from "lucide-react";
|
||||
|
||||
interface StudyActivityProps {
|
||||
studyId: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: number;
|
||||
type: "member_added" | "member_role_changed" | "study_updated" | "participant_added" | "participant_updated";
|
||||
description: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
function getActivityIcon(type: ActivityItem["type"]) {
|
||||
switch (type) {
|
||||
case "member_added":
|
||||
return <UserPlus className="h-4 w-4" />;
|
||||
case "member_role_changed":
|
||||
return <Settings className="h-4 w-4" />;
|
||||
case "study_updated":
|
||||
return <FileEdit className="h-4 w-4" />;
|
||||
case "participant_added":
|
||||
case "participant_updated":
|
||||
return <User className="h-4 w-4" />;
|
||||
default:
|
||||
return <Activity className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function StudyActivity({ studyId, role }: StudyActivityProps) {
|
||||
const { data: activities } = api.study.getActivities.useQuery({ studyId });
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Activity Log</CardTitle>
|
||||
<CardDescription>Recent activity in this study</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!activities || activities.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No activity recorded yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{activities.map((activity) => (
|
||||
<div key={activity.id} className="flex gap-4">
|
||||
<div className="mt-1">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
|
||||
{getActivityIcon(activity.type)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activity.userName}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{activity.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{format(new Date(activity.createdAt), "PPpp")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
25
src/components/studies/study-card.tsx
Normal file
25
src/components/studies/study-card.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Link from "next/link";
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
||||
|
||||
interface StudyCardProps {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function StudyCard({ id, title, description, role }: StudyCardProps) {
|
||||
return (
|
||||
<Link href={`/studies/${id}`}>
|
||||
<Card className="hover:bg-muted/50 transition-colors">
|
||||
<CardHeader>
|
||||
<CardTitle>{title}</CardTitle>
|
||||
<CardDescription>{description}</CardDescription>
|
||||
<div className="text-sm text-muted-foreground mt-2">
|
||||
Role: {role}
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
86
src/components/studies/study-form.tsx
Normal file
86
src/components/studies/study-form.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
|
||||
const studyFormSchema = z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export type StudyFormValues = z.infer<typeof studyFormSchema>;
|
||||
|
||||
interface StudyFormProps {
|
||||
defaultValues?: StudyFormValues;
|
||||
onSubmit: (data: StudyFormValues) => void;
|
||||
isSubmitting?: boolean;
|
||||
submitLabel?: string;
|
||||
}
|
||||
|
||||
export function StudyForm({
|
||||
defaultValues = {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = "Save",
|
||||
}: StudyFormProps) {
|
||||
const form = useForm<StudyFormValues>({
|
||||
resolver: zodResolver(studyFormSchema),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Title</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Enter study title" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter study description"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Saving..." : submitLabel}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
372
src/components/studies/study-members.tsx
Normal file
372
src/components/studies/study-members.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "~/components/ui/alert-dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
import { Plus, UserPlus, Crown } from "lucide-react";
|
||||
|
||||
interface StudyMembersProps {
|
||||
studyId: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function StudyMembers({ studyId, role }: StudyMembersProps) {
|
||||
const { data: session } = useSession();
|
||||
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
||||
const [isTransferOpen, setIsTransferOpen] = useState(false);
|
||||
const [transferToUserId, setTransferToUserId] = useState<string | null>(null);
|
||||
const [email, setEmail] = useState("");
|
||||
const [selectedRole, setSelectedRole] = useState(ROLES.RESEARCHER);
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: members, refetch: refetchMembers } = api.study.getMembers.useQuery({ studyId });
|
||||
const { data: pendingInvitations, refetch: refetchInvitations } = api.study.getPendingInvitations.useQuery({ studyId });
|
||||
|
||||
const { mutate: inviteMember, isPending: isInviting } = api.study.inviteMember.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Member invited successfully",
|
||||
});
|
||||
setIsInviteOpen(false);
|
||||
setEmail("");
|
||||
setSelectedRole(ROLES.RESEARCHER);
|
||||
refetchMembers();
|
||||
refetchInvitations();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: transferOwnership, isPending: isTransferring } = api.study.transferOwnership.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Study ownership transferred successfully",
|
||||
});
|
||||
setIsTransferOpen(false);
|
||||
setTransferToUserId(null);
|
||||
refetchMembers();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: revokeInvitation } = api.study.revokeInvitation.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Invitation revoked successfully",
|
||||
});
|
||||
refetchInvitations();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: updateMemberRole } = api.study.updateMemberRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Member role updated successfully",
|
||||
});
|
||||
refetchMembers();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const canManageMembers = role.toUpperCase() === ROLES.OWNER.toUpperCase() || role.toUpperCase() === ROLES.ADMIN.toUpperCase();
|
||||
const isOwner = role.toUpperCase() === ROLES.OWNER.toUpperCase();
|
||||
|
||||
// Get available roles based on current user's role
|
||||
const getAvailableRoles = (userRole: string) => {
|
||||
const roleHierarchy = {
|
||||
[ROLES.OWNER.toUpperCase()]: [ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
|
||||
[ROLES.ADMIN.toUpperCase()]: [ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
|
||||
[ROLES.PRINCIPAL_INVESTIGATOR.toUpperCase()]: [ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
|
||||
};
|
||||
|
||||
return roleHierarchy[userRole.toUpperCase()] ?? [];
|
||||
};
|
||||
|
||||
const availableRoles = getAvailableRoles(role);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Study Members</CardTitle>
|
||||
<CardDescription>Manage members and their roles</CardDescription>
|
||||
</div>
|
||||
{canManageMembers && (
|
||||
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Invite Member
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Invite New Member</DialogTitle>
|
||||
<DialogDescription>
|
||||
Enter the email address of the person you want to invite to this study.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter email address"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role">Role</Label>
|
||||
<Select
|
||||
value={selectedRole}
|
||||
onValueChange={setSelectedRole}
|
||||
defaultValue={availableRoles[0]}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableRoles.map((availableRole) => (
|
||||
<SelectItem key={availableRole} value={availableRole}>
|
||||
{availableRole === ROLES.PRINCIPAL_INVESTIGATOR ? "Principal Investigator" : availableRole}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => inviteMember({ studyId, email, role: selectedRole })}
|
||||
disabled={isInviting}
|
||||
>
|
||||
{isInviting ? "Inviting..." : "Send Invite"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!members || members.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No members found
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
{canManageMembers && <TableHead>Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{members.map((member) => (
|
||||
<TableRow key={member.userId}>
|
||||
<TableCell>{member.name}</TableCell>
|
||||
<TableCell>{member.email}</TableCell>
|
||||
<TableCell>
|
||||
{canManageMembers && member.role.toUpperCase() !== ROLES.OWNER.toUpperCase() ? (
|
||||
<Select
|
||||
value={member.role}
|
||||
onValueChange={(newRole) =>
|
||||
updateMemberRole({
|
||||
studyId,
|
||||
userId: member.userId,
|
||||
role: newRole,
|
||||
})
|
||||
}
|
||||
disabled={member.userId === session?.user.id}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ROLES.ADMIN}>Admin</SelectItem>
|
||||
<SelectItem value={ROLES.PRINCIPAL_INVESTIGATOR}>Principal Investigator</SelectItem>
|
||||
<SelectItem value={ROLES.RESEARCHER}>Researcher</SelectItem>
|
||||
<SelectItem value={ROLES.OBSERVER}>Observer</SelectItem>
|
||||
<SelectItem value={ROLES.WIZARD}>Wizard</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="px-3 py-2">{member.role}</div>
|
||||
)}
|
||||
</TableCell>
|
||||
{canManageMembers && (
|
||||
<TableCell>
|
||||
{isOwner && member.userId !== session?.user.id && (
|
||||
<AlertDialog open={isTransferOpen && transferToUserId === member.userId} onOpenChange={(open) => {
|
||||
setIsTransferOpen(open);
|
||||
if (!open) setTransferToUserId(null);
|
||||
}}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTransferToUserId(member.userId);
|
||||
setIsTransferOpen(true);
|
||||
}}
|
||||
>
|
||||
<Crown className="h-4 w-4 mr-2" />
|
||||
Transfer Ownership
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Transfer Study Ownership</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to transfer ownership to {member.name}? This action cannot be undone.
|
||||
You will become an admin of the study.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => {
|
||||
transferOwnership({
|
||||
studyId,
|
||||
newOwnerId: member.userId,
|
||||
});
|
||||
}}
|
||||
disabled={isTransferring}
|
||||
>
|
||||
{isTransferring ? "Transferring..." : "Transfer Ownership"}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{canManageMembers && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pending Invitations</CardTitle>
|
||||
<CardDescription>Manage outstanding invitations to join the study</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!pendingInvitations || pendingInvitations.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No pending invitations
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Invited By</TableHead>
|
||||
<TableHead>Expires</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pendingInvitations.map((invitation) => (
|
||||
<TableRow key={invitation.id}>
|
||||
<TableCell>{invitation.email}</TableCell>
|
||||
<TableCell>{invitation.role}</TableCell>
|
||||
<TableCell>{invitation.creatorName}</TableCell>
|
||||
<TableCell>{new Date(invitation.expiresAt).toLocaleDateString()}</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => revokeInvitation({ studyId, invitationId: invitation.id })}
|
||||
>
|
||||
Revoke
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
src/components/studies/study-metadata.tsx
Normal file
178
src/components/studies/study-metadata.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { api } from "~/trpc/react";
|
||||
import { useToast } from "~/hooks/use-toast";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
|
||||
interface StudyMetadataProps {
|
||||
studyId: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function StudyMetadata({ studyId, role }: StudyMetadataProps) {
|
||||
const [isAddOpen, setIsAddOpen] = useState(false);
|
||||
const [key, setKey] = useState("");
|
||||
const [value, setValue] = useState("");
|
||||
const { toast } = useToast();
|
||||
|
||||
const { data: metadata, refetch } = api.study.getMetadata.useQuery({ studyId });
|
||||
const { mutate: addMetadata, isPending: isAdding } = api.study.addMetadata.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Metadata added successfully",
|
||||
});
|
||||
setIsAddOpen(false);
|
||||
setKey("");
|
||||
setValue("");
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteMetadata } = api.study.deleteMetadata.useMutation({
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Metadata deleted successfully",
|
||||
});
|
||||
refetch();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const canManageMetadata = role === "ADMIN";
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Study Metadata</CardTitle>
|
||||
<CardDescription>Custom fields and tags for this study</CardDescription>
|
||||
</div>
|
||||
{canManageMetadata && (
|
||||
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Field
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Metadata Field</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add a new custom field or tag to this study.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="key">Field Name</Label>
|
||||
<Input
|
||||
id="key"
|
||||
placeholder="Enter field name"
|
||||
value={key}
|
||||
onChange={(e) => setKey(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="value">Value</Label>
|
||||
<Input
|
||||
id="value"
|
||||
placeholder="Enter value"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={() => addMetadata({ studyId, key, value })}
|
||||
disabled={isAdding}
|
||||
>
|
||||
{isAdding ? "Adding..." : "Add Field"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!metadata || metadata.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No metadata fields found
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Field</TableHead>
|
||||
<TableHead>Value</TableHead>
|
||||
{canManageMetadata && <TableHead className="w-[100px]">Actions</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{metadata.map((item) => (
|
||||
<TableRow key={item.key}>
|
||||
<TableCell className="font-medium">{item.key}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{item.value}</Badge>
|
||||
</TableCell>
|
||||
{canManageMetadata && (
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => deleteMetadata({ studyId, key: item.key })}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Delete</span>
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
84
src/components/studies/study-overview.tsx
Normal file
84
src/components/studies/study-overview.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Users, Calendar, Clock } from "lucide-react";
|
||||
|
||||
interface StudyOverviewProps {
|
||||
study: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string | null;
|
||||
role: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function StudyOverview({ study }: StudyOverviewProps) {
|
||||
const { data: participantCount } = api.participant.getCount.useQuery({ studyId: study.id });
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Study Details</CardTitle>
|
||||
<CardDescription>Basic information about the study</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<dl className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Your Role</dt>
|
||||
<dd className="mt-1 text-sm">{study.role}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-muted-foreground">Description</dt>
|
||||
<dd className="mt-1 text-sm">{study.description || "No description provided"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{participantCount ?? 0}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Active participants in study
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Last Activity</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">—</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Most recent update
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Study Duration</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">—</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Days since creation
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
166
src/components/studies/study-participants.tsx
Normal file
166
src/components/studies/study-participants.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "~/components/ui/table";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { api } from "~/trpc/react";
|
||||
import { Plus as PlusIcon, Eye, EyeOff } from "lucide-react";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { useState } from "react";
|
||||
|
||||
interface StudyParticipantsProps {
|
||||
studyId: number;
|
||||
role: string;
|
||||
}
|
||||
|
||||
export function StudyParticipants({ studyId, role }: StudyParticipantsProps) {
|
||||
const router = useRouter();
|
||||
const { data: participants, isLoading } = api.participant.getByStudyId.useQuery({ studyId });
|
||||
const [showIdentifiable, setShowIdentifiable] = useState(false);
|
||||
|
||||
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(role.toLowerCase());
|
||||
|
||||
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
|
||||
.map(r => r.toLowerCase())
|
||||
.includes(role.toLowerCase());
|
||||
|
||||
if (isLoading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Study Participants</CardTitle>
|
||||
{!canViewIdentifiableInfo ? (
|
||||
<CardDescription className="text-yellow-600">
|
||||
Personal information is redacted based on your role.
|
||||
</CardDescription>
|
||||
) : (
|
||||
<CardDescription>
|
||||
{showIdentifiable
|
||||
? "Showing personal information."
|
||||
: "Personal information is hidden."}
|
||||
</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
{canViewIdentifiableInfo && (
|
||||
<div className="flex items-center gap-2 border rounded-lg p-2 bg-muted/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="show-identifiable"
|
||||
checked={showIdentifiable}
|
||||
onCheckedChange={setShowIdentifiable}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-identifiable"
|
||||
className="text-sm font-medium flex items-center gap-2 cursor-pointer select-none"
|
||||
>
|
||||
{showIdentifiable ? (
|
||||
<>
|
||||
<Eye className="h-4 w-4" />
|
||||
Personal Info Visible
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
Personal Info Hidden
|
||||
</>
|
||||
)}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{canManageParticipants && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => router.push(`/dashboard/studies/${studyId}/participants/new`)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add Participant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!participants || participants.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
No participants have been added to this study yet.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
{(canViewIdentifiableInfo && showIdentifiable) && (
|
||||
<>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
</>
|
||||
)}
|
||||
<TableHead>Notes</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{participants.map((participant) => (
|
||||
<TableRow
|
||||
key={participant.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() =>
|
||||
router.push(`/dashboard/studies/${studyId}/participants/${participant.id}`)
|
||||
}
|
||||
>
|
||||
<TableCell>{participant.identifier || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={
|
||||
participant.status === "active"
|
||||
? "default"
|
||||
: participant.status === "completed"
|
||||
? "secondary"
|
||||
: "outline"
|
||||
}
|
||||
>
|
||||
{participant.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{(canViewIdentifiableInfo && showIdentifiable) && (
|
||||
<>
|
||||
<TableCell>
|
||||
{participant.firstName && participant.lastName
|
||||
? `${participant.firstName} ${participant.lastName}`
|
||||
: "—"}
|
||||
</TableCell>
|
||||
<TableCell>{participant.email || "—"}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell className="max-w-[200px] truncate">
|
||||
{participant.notes || "—"}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
141
src/components/ui/alert-dialog.tsx
Normal file
141
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { buttonVariants } from "~/components/ui/button"
|
||||
|
||||
const AlertDialog = AlertDialogPrimitive.Root
|
||||
|
||||
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||
|
||||
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
))
|
||||
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
))
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName =
|
||||
AlertDialogPrimitive.Description.displayName
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Action
|
||||
ref={ref}
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"mt-2 sm:mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
59
src/components/ui/alert.tsx
Normal file
59
src/components/ui/alert.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
36
src/components/ui/badge.tsx
Normal file
36
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -5,21 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "underline-offset-4 hover:underline text-primary",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 py-2 px-4",
|
||||
sm: "h-9 px-3 rounded-md",
|
||||
lg: "h-11 px-8 rounded-md",
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
29
src/components/ui/switch.tsx
Normal file
29
src/components/ui/switch.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SwitchPrimitives from "@radix-ui/react-switch"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitives.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
35
src/env.ts
Normal file
35
src/env.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createEnv } from "@t3-oss/env-nextjs";
|
||||
import { z } from "zod";
|
||||
|
||||
export const env = createEnv({
|
||||
server: {
|
||||
NODE_ENV: z.enum(["development", "test", "production"]),
|
||||
DATABASE_URL: z.string().url(),
|
||||
NEXTAUTH_SECRET: z.string().min(1),
|
||||
NEXTAUTH_URL: z.string().url(),
|
||||
// Email configuration
|
||||
SMTP_HOST: z.string(),
|
||||
SMTP_PORT: z.string().transform(Number),
|
||||
SMTP_USER: z.string(),
|
||||
SMTP_PASS: z.string(),
|
||||
EMAIL_FROM_NAME: z.string(),
|
||||
EMAIL_FROM_ADDRESS: z.string().email(),
|
||||
},
|
||||
client: {
|
||||
// Add client-side env vars here
|
||||
},
|
||||
runtimeEnv: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
DATABASE_URL: process.env.DATABASE_URL,
|
||||
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
|
||||
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
|
||||
// Email configuration
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_USER: process.env.SMTP_USER,
|
||||
SMTP_PASS: process.env.SMTP_PASS,
|
||||
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
|
||||
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
});
|
||||
@@ -1,128 +1,187 @@
|
||||
export const PERMISSIONS = {
|
||||
// Study permissions
|
||||
// Study Management
|
||||
CREATE_STUDY: "create_study",
|
||||
EDIT_STUDY: "edit_study",
|
||||
DELETE_STUDY: "delete_study",
|
||||
TRANSFER_OWNERSHIP: "transfer_ownership",
|
||||
VIEW_STUDY: "view_study",
|
||||
MANAGE_STUDY_METADATA: "manage_study_metadata",
|
||||
|
||||
// Participant permissions
|
||||
VIEW_PARTICIPANT_NAMES: "view_participant_names",
|
||||
CREATE_PARTICIPANT: "create_participant",
|
||||
// Participant Management
|
||||
ADD_PARTICIPANT: "add_participant",
|
||||
EDIT_PARTICIPANT: "edit_participant",
|
||||
DELETE_PARTICIPANT: "delete_participant",
|
||||
VIEW_PARTICIPANT_IDENTIFIABLE: "view_participant_identifiable",
|
||||
VIEW_PARTICIPANT_ANONYMIZED: "view_participant_anonymized",
|
||||
MANAGE_CONSENT_FORMS: "manage_consent_forms",
|
||||
|
||||
// Robot permissions
|
||||
// Experiment Design
|
||||
CREATE_EXPERIMENT: "create_experiment",
|
||||
EDIT_EXPERIMENT: "edit_experiment",
|
||||
DELETE_EXPERIMENT: "delete_experiment",
|
||||
DEFINE_ROBOT_BEHAVIORS: "define_robot_behaviors",
|
||||
CONFIGURE_DATA_COLLECTION: "configure_data_collection",
|
||||
|
||||
// Experiment Execution
|
||||
RUN_TRIALS: "run_trials",
|
||||
CONTROL_ROBOT: "control_robot",
|
||||
VIEW_ROBOT_STATUS: "view_robot_status",
|
||||
MONITOR_SESSIONS: "monitor_sessions",
|
||||
ADD_REALTIME_ANNOTATIONS: "add_realtime_annotations",
|
||||
|
||||
// Experiment permissions
|
||||
RECORD_EXPERIMENT: "record_experiment",
|
||||
VIEW_EXPERIMENT: "view_experiment",
|
||||
VIEW_EXPERIMENT_DATA: "view_experiment_data",
|
||||
EXPORT_EXPERIMENT_DATA: "export_experiment_data",
|
||||
ANNOTATE_EXPERIMENT: "annotate_experiment",
|
||||
// Data Access
|
||||
VIEW_RAW_DATA: "view_raw_data",
|
||||
VIEW_ANONYMIZED_DATA: "view_anonymized_data",
|
||||
EXPORT_DATA: "export_data",
|
||||
|
||||
// Administrative permissions
|
||||
MANAGE_ROLES: "manage_roles",
|
||||
MANAGE_USERS: "manage_users",
|
||||
MANAGE_SYSTEM_SETTINGS: "manage_system_settings",
|
||||
// User Management
|
||||
INVITE_USERS: "invite_users",
|
||||
ASSIGN_ROLES: "assign_roles",
|
||||
REMOVE_USERS: "remove_users",
|
||||
MANAGE_PERMISSIONS: "manage_permissions",
|
||||
} as const;
|
||||
|
||||
export type Permission = keyof typeof PERMISSIONS;
|
||||
export type PermissionValue = (typeof PERMISSIONS)[Permission];
|
||||
|
||||
export const ROLES = {
|
||||
OWNER: "owner",
|
||||
ADMIN: "admin",
|
||||
PRINCIPAL_INVESTIGATOR: "principal_investigator",
|
||||
RESEARCHER: "researcher",
|
||||
WIZARD: "wizard",
|
||||
RESEARCHER: "researcher",
|
||||
OBSERVER: "observer",
|
||||
ASSISTANT: "assistant",
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof ROLES;
|
||||
export type RoleValue = (typeof ROLES)[Role];
|
||||
|
||||
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
|
||||
ADMIN: Object.keys(PERMISSIONS) as Permission[],
|
||||
OWNER: Object.keys(PERMISSIONS) as Permission[],
|
||||
|
||||
PRINCIPAL_INVESTIGATOR: [
|
||||
"CREATE_STUDY",
|
||||
ADMIN: [
|
||||
"EDIT_STUDY",
|
||||
"DELETE_STUDY",
|
||||
"VIEW_STUDY",
|
||||
"VIEW_PARTICIPANT_NAMES",
|
||||
"CREATE_PARTICIPANT",
|
||||
"MANAGE_STUDY_METADATA",
|
||||
"ADD_PARTICIPANT",
|
||||
"EDIT_PARTICIPANT",
|
||||
"DELETE_PARTICIPANT",
|
||||
"VIEW_ROBOT_STATUS",
|
||||
"VIEW_EXPERIMENT",
|
||||
"VIEW_EXPERIMENT_DATA",
|
||||
"EXPORT_EXPERIMENT_DATA",
|
||||
"ANNOTATE_EXPERIMENT",
|
||||
"MANAGE_ROLES",
|
||||
"MANAGE_USERS",
|
||||
"VIEW_PARTICIPANT_IDENTIFIABLE",
|
||||
"VIEW_PARTICIPANT_ANONYMIZED",
|
||||
"MANAGE_CONSENT_FORMS",
|
||||
"CREATE_EXPERIMENT",
|
||||
"EDIT_EXPERIMENT",
|
||||
"DELETE_EXPERIMENT",
|
||||
"DEFINE_ROBOT_BEHAVIORS",
|
||||
"CONFIGURE_DATA_COLLECTION",
|
||||
"RUN_TRIALS",
|
||||
"CONTROL_ROBOT",
|
||||
"MONITOR_SESSIONS",
|
||||
"ADD_REALTIME_ANNOTATIONS",
|
||||
"VIEW_RAW_DATA",
|
||||
"VIEW_ANONYMIZED_DATA",
|
||||
"EXPORT_DATA",
|
||||
"INVITE_USERS",
|
||||
"ASSIGN_ROLES",
|
||||
"REMOVE_USERS",
|
||||
],
|
||||
|
||||
RESEARCHER: [
|
||||
PRINCIPAL_INVESTIGATOR: [
|
||||
"VIEW_STUDY",
|
||||
"VIEW_ROBOT_STATUS",
|
||||
"VIEW_EXPERIMENT",
|
||||
"VIEW_EXPERIMENT_DATA",
|
||||
"EXPORT_EXPERIMENT_DATA",
|
||||
"ANNOTATE_EXPERIMENT",
|
||||
"ADD_PARTICIPANT",
|
||||
"EDIT_PARTICIPANT",
|
||||
"DELETE_PARTICIPANT",
|
||||
"VIEW_PARTICIPANT_IDENTIFIABLE",
|
||||
"VIEW_PARTICIPANT_ANONYMIZED",
|
||||
"MANAGE_CONSENT_FORMS",
|
||||
"CREATE_EXPERIMENT",
|
||||
"EDIT_EXPERIMENT",
|
||||
"DELETE_EXPERIMENT",
|
||||
"DEFINE_ROBOT_BEHAVIORS",
|
||||
"CONFIGURE_DATA_COLLECTION",
|
||||
"RUN_TRIALS",
|
||||
"CONTROL_ROBOT",
|
||||
"MONITOR_SESSIONS",
|
||||
"ADD_REALTIME_ANNOTATIONS",
|
||||
"VIEW_RAW_DATA",
|
||||
"VIEW_ANONYMIZED_DATA",
|
||||
"EXPORT_DATA",
|
||||
"INVITE_USERS",
|
||||
],
|
||||
|
||||
WIZARD: [
|
||||
"VIEW_STUDY",
|
||||
"VIEW_ROBOT_STATUS",
|
||||
"VIEW_PARTICIPANT_ANONYMIZED",
|
||||
"RUN_TRIALS",
|
||||
"CONTROL_ROBOT",
|
||||
"RECORD_EXPERIMENT",
|
||||
"VIEW_EXPERIMENT",
|
||||
"ANNOTATE_EXPERIMENT",
|
||||
"MONITOR_SESSIONS",
|
||||
"ADD_REALTIME_ANNOTATIONS",
|
||||
"VIEW_ANONYMIZED_DATA",
|
||||
],
|
||||
|
||||
RESEARCHER: [
|
||||
"VIEW_STUDY",
|
||||
"VIEW_PARTICIPANT_ANONYMIZED",
|
||||
"MONITOR_SESSIONS",
|
||||
"ADD_REALTIME_ANNOTATIONS",
|
||||
"VIEW_ANONYMIZED_DATA",
|
||||
"EXPORT_DATA",
|
||||
],
|
||||
|
||||
OBSERVER: [
|
||||
"VIEW_STUDY",
|
||||
"VIEW_ROBOT_STATUS",
|
||||
"VIEW_EXPERIMENT",
|
||||
"VIEW_EXPERIMENT_DATA",
|
||||
"ANNOTATE_EXPERIMENT",
|
||||
],
|
||||
|
||||
ASSISTANT: [
|
||||
"VIEW_STUDY",
|
||||
"VIEW_ROBOT_STATUS",
|
||||
"VIEW_EXPERIMENT",
|
||||
"VIEW_PARTICIPANT_ANONYMIZED",
|
||||
"MONITOR_SESSIONS",
|
||||
"ADD_REALTIME_ANNOTATIONS",
|
||||
],
|
||||
};
|
||||
|
||||
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
|
||||
ADMIN: "Full system administrator with all permissions",
|
||||
PRINCIPAL_INVESTIGATOR: "Lead researcher responsible for study design and oversight",
|
||||
RESEARCHER: "Study team member with access to anonymized data and experiment monitoring capabilities",
|
||||
WIZARD: "Operator controlling robot behavior during experiments",
|
||||
OBSERVER: "Team member observing and annotating experiments",
|
||||
ASSISTANT: "Support staff with limited view access",
|
||||
OWNER: "Study owner with full control and exclusive ability to delete study or transfer ownership",
|
||||
ADMIN: "Administrator with ability to manage participants, experiments, and other members",
|
||||
PRINCIPAL_INVESTIGATOR: "Scientific lead with full access to participant data and experiment design",
|
||||
WIZARD: "Operator controlling robot behavior during experiment trials",
|
||||
RESEARCHER: "Team member who can analyze experiment data and results",
|
||||
OBSERVER: "Team member who can view experiments and add annotations",
|
||||
};
|
||||
|
||||
export const PERMISSION_DESCRIPTIONS: Record<Permission, string> = {
|
||||
// Study Management
|
||||
CREATE_STUDY: "Create new research studies",
|
||||
EDIT_STUDY: "Modify existing study parameters",
|
||||
DELETE_STUDY: "Remove studies from the system",
|
||||
TRANSFER_OWNERSHIP: "Transfer study ownership to another user",
|
||||
VIEW_STUDY: "View study details and progress",
|
||||
VIEW_PARTICIPANT_NAMES: "Access participant identifying information",
|
||||
CREATE_PARTICIPANT: "Add new participants to studies",
|
||||
MANAGE_STUDY_METADATA: "Manage study metadata and settings",
|
||||
|
||||
// Participant Management
|
||||
ADD_PARTICIPANT: "Add new participants to studies",
|
||||
EDIT_PARTICIPANT: "Update participant information",
|
||||
DELETE_PARTICIPANT: "Remove participants from studies",
|
||||
CONTROL_ROBOT: "Operate robot during experiments",
|
||||
VIEW_ROBOT_STATUS: "Monitor robot state and sensors",
|
||||
RECORD_EXPERIMENT: "Start/stop experiment recording",
|
||||
VIEW_EXPERIMENT: "View experiment progress and details",
|
||||
VIEW_EXPERIMENT_DATA: "Access collected experiment data",
|
||||
EXPORT_EXPERIMENT_DATA: "Download experiment data",
|
||||
ANNOTATE_EXPERIMENT: "Add notes and annotations to experiments",
|
||||
MANAGE_ROLES: "Assign and modify user roles",
|
||||
MANAGE_USERS: "Add and remove system users",
|
||||
MANAGE_SYSTEM_SETTINGS: "Configure system-wide settings",
|
||||
VIEW_PARTICIPANT_IDENTIFIABLE: "Access participant identifying information",
|
||||
VIEW_PARTICIPANT_ANONYMIZED: "View anonymized participant data",
|
||||
MANAGE_CONSENT_FORMS: "Manage participant consent forms",
|
||||
|
||||
// Experiment Design
|
||||
CREATE_EXPERIMENT: "Create new experiments",
|
||||
EDIT_EXPERIMENT: "Modify existing experiments",
|
||||
DELETE_EXPERIMENT: "Remove experiments from studies",
|
||||
DEFINE_ROBOT_BEHAVIORS: "Define and configure robot behaviors",
|
||||
CONFIGURE_DATA_COLLECTION: "Configure experiment data collection",
|
||||
|
||||
// Experiment Execution
|
||||
RUN_TRIALS: "Execute experiment trials",
|
||||
CONTROL_ROBOT: "Control robot during experiments",
|
||||
MONITOR_SESSIONS: "Monitor live experiment sessions",
|
||||
ADD_REALTIME_ANNOTATIONS: "Add annotations during experiments",
|
||||
|
||||
// Data Access
|
||||
VIEW_RAW_DATA: "Access raw experiment data",
|
||||
VIEW_ANONYMIZED_DATA: "Access anonymized experiment data",
|
||||
EXPORT_DATA: "Export experiment data",
|
||||
|
||||
// User Management
|
||||
INVITE_USERS: "Invite new users to the study",
|
||||
ASSIGN_ROLES: "Assign roles to study members",
|
||||
REMOVE_USERS: "Remove users from the study",
|
||||
MANAGE_PERMISSIONS: "Manage user permissions",
|
||||
};
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
import { type Permission, type PermissionValue } from "./constants";
|
||||
import { auth } from "~/server/auth";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { studyMembers } from "~/server/db/schema";
|
||||
import { ROLE_PERMISSIONS, ROLES, PERMISSIONS } from "./constants";
|
||||
import type { Session } from "next-auth";
|
||||
import { studies } from "~/server/db/schema";
|
||||
|
||||
export async function getUserPermissions(userId: string, studyId?: number) {
|
||||
const conditions = [eq(userRoles.userId, userId)];
|
||||
@@ -110,42 +115,62 @@ export async function getUserStudyRoles(userId: string, studyId: number) {
|
||||
);
|
||||
}
|
||||
|
||||
interface PermissionCheck {
|
||||
studyId: number;
|
||||
permission?: PermissionValue;
|
||||
requireStudyAccess?: boolean;
|
||||
interface CheckPermissionsOptions {
|
||||
studyId?: number;
|
||||
permission: Permission;
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
export async function checkPermissions(check: PermissionCheck) {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
export async function checkPermissions({
|
||||
studyId,
|
||||
permission,
|
||||
session,
|
||||
}: CheckPermissionsOptions): Promise<void> {
|
||||
if (!session?.user) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "You must be logged in to perform this action",
|
||||
});
|
||||
}
|
||||
|
||||
const { studyId, permission, requireStudyAccess = true } = check;
|
||||
|
||||
if (requireStudyAccess) {
|
||||
const hasAccess = await hasStudyAccess(session.user.id, studyId);
|
||||
if (!hasAccess) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
});
|
||||
// Anyone who is logged in can create a study
|
||||
if (!studyId) {
|
||||
if (permission === "CREATE_STUDY") {
|
||||
return;
|
||||
}
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Study ID is required for this action",
|
||||
});
|
||||
}
|
||||
|
||||
if (permission) {
|
||||
const hasRequiredPermission = await hasPermission(session.user.id, permission, studyId);
|
||||
if (!hasRequiredPermission) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You don't have permission to perform this action",
|
||||
});
|
||||
}
|
||||
const membership = await db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, studyId),
|
||||
eq(studyMembers.userId, session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to perform this action",
|
||||
});
|
||||
}
|
||||
|
||||
return { userId: session.user.id };
|
||||
// Normalize role (convert membership.role to uppercase) so that it matches the keys in ROLE_PERMISSIONS
|
||||
const normalizedRole = membership.role.toUpperCase() as keyof typeof ROLE_PERMISSIONS;
|
||||
const permittedActions = ROLE_PERMISSIONS[normalizedRole] ?? [];
|
||||
|
||||
// For owners, they have all permissions
|
||||
if (normalizedRole === "OWNER") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!permittedActions.includes(permission)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to perform this action",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,131 +1,246 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, eq, count } from "drizzle-orm"
|
||||
import { TRPCError } from "@trpc/server"
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"
|
||||
import { participants } from "~/server/db/schema"
|
||||
import { participants, studyMembers, type ParticipantStatus } from "~/server/db/schema"
|
||||
import { ROLES, PERMISSIONS } from "~/lib/permissions/constants"
|
||||
import { checkPermissions } from "~/lib/permissions/server"
|
||||
import { PERMISSIONS } from "~/lib/permissions/constants"
|
||||
import { studyActivities } from "~/server/db/schema/studies"
|
||||
|
||||
const createParticipantSchema = z.object({
|
||||
studyId: z.string().uuid(),
|
||||
identifier: z.string().min(1).max(256),
|
||||
email: z.string().email().optional(),
|
||||
firstName: z.string().max(256).optional(),
|
||||
lastName: z.string().max(256).optional(),
|
||||
studyId: z.number(),
|
||||
identifier: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
status: z.enum(["active", "inactive"]).default("active"),
|
||||
status: z.enum(["active", "inactive", "completed", "withdrawn"]).default("active"),
|
||||
})
|
||||
|
||||
const updateParticipantSchema = z.object({
|
||||
id: z.number(),
|
||||
identifier: z.string().optional(),
|
||||
email: z.string().email().optional().or(z.literal("")),
|
||||
firstName: z.string().optional(),
|
||||
lastName: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
status: z.enum(["active", "inactive", "completed", "withdrawn"]).optional(),
|
||||
})
|
||||
|
||||
export const participantRouter = createTRPCRouter({
|
||||
getAll: protectedProcedure
|
||||
.input(z.object({ studyId: z.string().uuid() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
await checkPermissions({
|
||||
studyId: input.studyId,
|
||||
permission: PERMISSIONS.VIEW_PARTICIPANTS,
|
||||
})
|
||||
|
||||
return ctx.db.query.participants.findMany({
|
||||
where: eq(participants.studyId, input.studyId),
|
||||
orderBy: (participants, { asc }) => [asc(participants.identifier)],
|
||||
})
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.input(z.object({ id: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, input.id),
|
||||
})
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to view participants
|
||||
await checkPermissions({
|
||||
studyId: participant.studyId,
|
||||
permission: PERMISSIONS.VIEW_PARTICIPANTS,
|
||||
})
|
||||
permission: "VIEW_PARTICIPANT_ANONYMIZED",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
return participant
|
||||
// Check if user has permission to view identifiable information
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, participant.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
const canViewIdentifiable = membership && [
|
||||
ROLES.OWNER.toLowerCase(),
|
||||
ROLES.ADMIN.toLowerCase(),
|
||||
ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()
|
||||
].includes(membership.role.toLowerCase());
|
||||
|
||||
if (!canViewIdentifiable) {
|
||||
return {
|
||||
...participant,
|
||||
identifier: participant.identifier ? "REDACTED" : null,
|
||||
email: participant.email ? "REDACTED" : null,
|
||||
firstName: participant.firstName ? "REDACTED" : null,
|
||||
lastName: participant.lastName ? "REDACTED" : null,
|
||||
};
|
||||
}
|
||||
|
||||
return participant;
|
||||
}),
|
||||
|
||||
getByStudyId: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user has permission to view participants
|
||||
await checkPermissions({
|
||||
studyId: input.studyId,
|
||||
permission: "VIEW_PARTICIPANT_ANONYMIZED",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
// Get participants
|
||||
const studyParticipants = await ctx.db.query.participants.findMany({
|
||||
where: eq(participants.studyId, input.studyId),
|
||||
orderBy: participants.createdAt,
|
||||
});
|
||||
|
||||
// Check if user has permission to view identifiable information
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
const canViewIdentifiable = membership && [
|
||||
ROLES.OWNER.toLowerCase(),
|
||||
ROLES.ADMIN.toLowerCase(),
|
||||
ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()
|
||||
].includes(membership.role.toLowerCase());
|
||||
|
||||
if (!canViewIdentifiable) {
|
||||
return studyParticipants.map(participant => ({
|
||||
...participant,
|
||||
identifier: participant.identifier ? "REDACTED" : null,
|
||||
email: participant.email ? "REDACTED" : null,
|
||||
firstName: participant.firstName ? "REDACTED" : null,
|
||||
lastName: participant.lastName ? "REDACTED" : null,
|
||||
}));
|
||||
}
|
||||
|
||||
return studyParticipants;
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(createParticipantSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user has permission to add participants
|
||||
await checkPermissions({
|
||||
studyId: input.studyId,
|
||||
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
|
||||
})
|
||||
permission: "ADD_PARTICIPANT",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const [participant] = await ctx.db
|
||||
.insert(participants)
|
||||
.values(input)
|
||||
.returning()
|
||||
.values({
|
||||
...input,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
|
||||
return participant
|
||||
// Log activity
|
||||
await ctx.db.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "participant_added",
|
||||
description: `Added participant ${input.identifier ?? 'without identifier'}`,
|
||||
});
|
||||
|
||||
return participant;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(z.object({
|
||||
id: z.string().uuid(),
|
||||
...createParticipantSchema.partial().shape,
|
||||
}))
|
||||
.input(updateParticipantSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { id, ...data } = input
|
||||
|
||||
// First get the participant to check study membership
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, id),
|
||||
})
|
||||
where: eq(participants.id, input.id),
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to edit participants
|
||||
await checkPermissions({
|
||||
studyId: participant.studyId,
|
||||
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
|
||||
})
|
||||
permission: "EDIT_PARTICIPANT",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const [updated] = await ctx.db
|
||||
const [updatedParticipant] = await ctx.db
|
||||
.update(participants)
|
||||
.set({
|
||||
...data,
|
||||
...input,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(participants.id, id))
|
||||
.returning()
|
||||
.where(eq(participants.id, input.id))
|
||||
.returning();
|
||||
|
||||
return updated
|
||||
// Log activity
|
||||
await ctx.db.insert(studyActivities).values({
|
||||
studyId: participant.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "participant_updated",
|
||||
description: `Updated participant ${participant.identifier ?? 'without identifier'}`,
|
||||
});
|
||||
|
||||
return updatedParticipant;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.string().uuid() }))
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// First get the participant to check study membership
|
||||
const participant = await ctx.db.query.participants.findFirst({
|
||||
where: eq(participants.id, input.id),
|
||||
})
|
||||
});
|
||||
|
||||
if (!participant) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Participant not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user has permission to delete participants
|
||||
await checkPermissions({
|
||||
studyId: participant.studyId,
|
||||
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
|
||||
})
|
||||
permission: "DELETE_PARTICIPANT",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
await ctx.db.delete(participants).where(eq(participants.id, input.id))
|
||||
// Log activity before deletion
|
||||
await ctx.db.insert(studyActivities).values({
|
||||
studyId: participant.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "participant_removed",
|
||||
description: `Removed participant ${participant.identifier ?? 'without identifier'}`,
|
||||
});
|
||||
|
||||
return { success: true }
|
||||
await ctx.db.delete(participants).where(eq(participants.id, input.id));
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
})
|
||||
|
||||
getCount: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user has permission to view participants
|
||||
await checkPermissions({
|
||||
studyId: input.studyId,
|
||||
permission: "VIEW_PARTICIPANT_ANONYMIZED",
|
||||
session: ctx.session,
|
||||
});
|
||||
|
||||
const [result] = await ctx.db
|
||||
.select({ count: count() })
|
||||
.from(participants)
|
||||
.where(eq(participants.studyId, input.studyId));
|
||||
|
||||
return result.count;
|
||||
}),
|
||||
});
|
||||
@@ -1,9 +1,29 @@
|
||||
import { z } from "zod"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { and, eq, desc } from "drizzle-orm"
|
||||
import { TRPCError } from "@trpc/server"
|
||||
import { randomBytes } from "crypto"
|
||||
import { addDays } from "date-fns"
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"
|
||||
import { studies, studyMembers } from "~/server/db/schema"
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"
|
||||
import { studies, studyMembers, studyMetadata, studyActivities, studyInvitations } from "~/server/db/schema/studies"
|
||||
import { users } from "~/server/db/schema/auth"
|
||||
import { checkPermissions } from "~/lib/permissions/server"
|
||||
import { type Permission } from "~/lib/permissions/constants"
|
||||
import { db } from '~/server/db'
|
||||
import { ROLES } from "~/lib/permissions/constants"
|
||||
import { EmailService } from "~/server/email/service"
|
||||
import { PERMISSIONS } from "~/lib/permissions/constants"
|
||||
|
||||
const createStudySchema = z.object({
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
const updateStudySchema = z.object({
|
||||
id: z.number(),
|
||||
title: z.string().min(1).max(255),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
export const studyRouter = createTRPCRouter({
|
||||
getMyStudies: protectedProcedure.query(async ({ ctx }) => {
|
||||
@@ -15,14 +35,10 @@ export const studyRouter = createTRPCRouter({
|
||||
role: studyMembers.role,
|
||||
})
|
||||
.from(studies)
|
||||
.innerJoin(
|
||||
studyMembers,
|
||||
eq(studies.id, studyMembers.studyId),
|
||||
)
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, studies.id))
|
||||
.where(eq(studyMembers.userId, ctx.session.user.id))
|
||||
.orderBy(studies.createdAt)
|
||||
|
||||
return myStudies
|
||||
.orderBy(studies.createdAt);
|
||||
return myStudies;
|
||||
}),
|
||||
|
||||
getById: protectedProcedure
|
||||
@@ -36,34 +52,23 @@ export const studyRouter = createTRPCRouter({
|
||||
role: studyMembers.role,
|
||||
})
|
||||
.from(studies)
|
||||
.innerJoin(
|
||||
studyMembers,
|
||||
eq(studies.id, studyMembers.studyId),
|
||||
)
|
||||
.where(
|
||||
eq(studies.id, input.id),
|
||||
eq(studyMembers.userId, ctx.session.user.id)
|
||||
)
|
||||
.innerJoin(studyMembers, eq(studyMembers.studyId, studies.id))
|
||||
.where(and(eq(studies.id, input.id), eq(studyMembers.userId, ctx.session.user.id)))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
.then((rows) => rows[0]);
|
||||
|
||||
if (!study) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return study
|
||||
return study;
|
||||
}),
|
||||
|
||||
create: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1).max(256),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.input(createStudySchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const study = await ctx.db.transaction(async (tx) => {
|
||||
const [newStudy] = await tx
|
||||
@@ -73,125 +78,808 @@ export const studyRouter = createTRPCRouter({
|
||||
description: input.description,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning()
|
||||
.returning();
|
||||
|
||||
if (!newStudy) {
|
||||
throw new Error("Failed to create study")
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create study",
|
||||
});
|
||||
}
|
||||
|
||||
// Assign creator as owner
|
||||
await tx.insert(studyMembers).values({
|
||||
studyId: newStudy.id,
|
||||
userId: ctx.session.user.id,
|
||||
role: "admin",
|
||||
})
|
||||
role: ROLES.OWNER,
|
||||
});
|
||||
|
||||
return newStudy
|
||||
})
|
||||
// Log activity
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: newStudy.id,
|
||||
userId: ctx.session.user.id,
|
||||
type: "study_created",
|
||||
description: "Created study and assigned as owner",
|
||||
});
|
||||
|
||||
return study
|
||||
return newStudy;
|
||||
});
|
||||
|
||||
return study;
|
||||
}),
|
||||
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
title: z.string().min(1).max(256),
|
||||
description: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.input(updateStudySchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user has access to study
|
||||
const member = await ctx.db
|
||||
.select({ role: studyMembers.role })
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.id),
|
||||
eq(studyMembers.userId, ctx.session.user.id)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
})
|
||||
}
|
||||
|
||||
if (member.role !== "admin") {
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to update this study",
|
||||
})
|
||||
message: "You do not have permission to edit this study",
|
||||
});
|
||||
}
|
||||
|
||||
const [study] = await ctx.db
|
||||
const [updatedStudy] = await ctx.db
|
||||
.update(studies)
|
||||
.set({
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(studies.id, input.id))
|
||||
.returning()
|
||||
.returning();
|
||||
|
||||
if (!study) {
|
||||
if (!updatedStudy) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return study
|
||||
return updatedStudy;
|
||||
}),
|
||||
|
||||
delete: protectedProcedure
|
||||
.input(z.object({ id: z.number() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user has access to study
|
||||
const member = await ctx.db
|
||||
.select({ role: studyMembers.role })
|
||||
.from(studyMembers)
|
||||
.where(
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.id),
|
||||
eq(studyMembers.userId, ctx.session.user.id)
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0])
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
})
|
||||
}
|
||||
|
||||
if (member.role !== "admin") {
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to delete this study",
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
// Delete study members first (foreign key constraint)
|
||||
await tx
|
||||
.delete(studyMembers)
|
||||
.where(eq(studyMembers.studyId, input.id))
|
||||
|
||||
// Then delete the study
|
||||
const [study] = await tx
|
||||
await tx.delete(studyMembers).where(eq(studyMembers.studyId, input.id));
|
||||
const [deletedStudy] = await tx
|
||||
.delete(studies)
|
||||
.where(eq(studies.id, input.id))
|
||||
.returning()
|
||||
.returning();
|
||||
|
||||
if (!study) {
|
||||
if (!deletedStudy) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
return { success: true }
|
||||
return { success: true };
|
||||
}),
|
||||
})
|
||||
|
||||
createMutation: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
title: z.string().min(1, "Title is required"),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
if (!ctx.session?.user?.id) {
|
||||
throw new TRPCError({
|
||||
code: "UNAUTHORIZED",
|
||||
message: "User must be authenticated to create a study",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await db
|
||||
.insert(studies)
|
||||
.values({
|
||||
title: input.title,
|
||||
description: input.description ?? "",
|
||||
createdById: ctx.session.user.id,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
return result[0];
|
||||
} catch (error) {
|
||||
throw new TRPCError({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
message: "Failed to create study",
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
getMembers: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user is a member of the study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to view study members",
|
||||
});
|
||||
}
|
||||
|
||||
const members = await ctx.db
|
||||
.select({
|
||||
userId: studyMembers.userId,
|
||||
role: studyMembers.role,
|
||||
email: users.email,
|
||||
firstName: users.firstName,
|
||||
lastName: users.lastName,
|
||||
})
|
||||
.from(studyMembers)
|
||||
.innerJoin(users, eq(users.id, studyMembers.userId))
|
||||
.where(eq(studyMembers.studyId, input.studyId));
|
||||
|
||||
return members.map(member => ({
|
||||
...member,
|
||||
name: member.firstName && member.lastName
|
||||
? `${member.firstName} ${member.lastName}`
|
||||
: "Unknown",
|
||||
}));
|
||||
}),
|
||||
|
||||
getPendingInvitations: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user is a member of the study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to view study invitations",
|
||||
});
|
||||
}
|
||||
|
||||
const invitations = await ctx.db
|
||||
.select({
|
||||
id: studyInvitations.id,
|
||||
email: studyInvitations.email,
|
||||
role: studyInvitations.role,
|
||||
createdAt: studyInvitations.createdAt,
|
||||
expiresAt: studyInvitations.expiresAt,
|
||||
creatorName: users.firstName,
|
||||
})
|
||||
.from(studyInvitations)
|
||||
.innerJoin(users, eq(users.id, studyInvitations.createdById))
|
||||
.where(and(
|
||||
eq(studyInvitations.studyId, input.studyId),
|
||||
eq(studyInvitations.status, "pending"),
|
||||
))
|
||||
.orderBy(desc(studyInvitations.createdAt));
|
||||
|
||||
return invitations;
|
||||
}),
|
||||
|
||||
revokeInvitation: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
invitationId: z.number(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user is an admin
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only admins can revoke invitations",
|
||||
});
|
||||
}
|
||||
|
||||
// Update invitation status
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
const [invitation] = await tx
|
||||
.update(studyInvitations)
|
||||
.set({
|
||||
status: "revoked",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(
|
||||
eq(studyInvitations.id, input.invitationId),
|
||||
eq(studyInvitations.studyId, input.studyId),
|
||||
eq(studyInvitations.status, "pending"),
|
||||
))
|
||||
.returning();
|
||||
|
||||
if (!invitation) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invitation not found or already used",
|
||||
});
|
||||
}
|
||||
|
||||
// Log activity
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "invitation_revoked",
|
||||
description: `Revoked invitation for ${invitation.email}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
inviteMember: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
email: z.string().email(),
|
||||
role: z.enum(["researcher", "observer", "wizard", "principal_investigator", "admin"] as const),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user has permission to invite members
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You must be a member to invite others",
|
||||
});
|
||||
}
|
||||
|
||||
// Define role hierarchy
|
||||
const roleHierarchy = {
|
||||
[ROLES.OWNER.toLowerCase()]: [ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
|
||||
[ROLES.ADMIN.toLowerCase()]: [ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
|
||||
[ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()]: [ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
|
||||
};
|
||||
|
||||
const userRole = membership.role.toLowerCase();
|
||||
const targetRole = input.role.toLowerCase();
|
||||
|
||||
// Check if user can invite with the specified role
|
||||
const allowedRoles = roleHierarchy[userRole] ?? [];
|
||||
if (!allowedRoles.includes(targetRole)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You cannot invite members with this role",
|
||||
});
|
||||
}
|
||||
|
||||
// Get study details for the email
|
||||
const study = await ctx.db.query.studies.findFirst({
|
||||
where: eq(studies.id, input.studyId),
|
||||
});
|
||||
|
||||
if (!study) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Study not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if there's already a pending invitation
|
||||
const existingInvitation = await ctx.db.query.studyInvitations.findFirst({
|
||||
where: and(
|
||||
eq(studyInvitations.studyId, input.studyId),
|
||||
eq(studyInvitations.email, input.email),
|
||||
eq(studyInvitations.status, "pending"),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingInvitation) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "An invitation has already been sent to this email",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if the user is already a member (if they exist)
|
||||
const existingUser = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.email, input.email),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
const existingMembership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, existingUser.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (existingMembership) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "User is already a member of this study",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Generate a secure random token
|
||||
const token = randomBytes(32).toString("hex");
|
||||
const expiresAt = addDays(new Date(), 7); // 7 days from now
|
||||
|
||||
// Create the invitation
|
||||
const [invitation] = await ctx.db
|
||||
.insert(studyInvitations)
|
||||
.values({
|
||||
studyId: input.studyId,
|
||||
email: input.email,
|
||||
role: input.role,
|
||||
token,
|
||||
expiresAt,
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Log invitation sent activity
|
||||
await ctx.db.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "invitation_sent",
|
||||
description: `Sent invitation to ${input.email} for role ${input.role}`,
|
||||
});
|
||||
|
||||
// Send the invitation email
|
||||
const inviteUrl = `${process.env.NEXTAUTH_URL}/invite?token=${token}`;
|
||||
const emailService = new EmailService();
|
||||
|
||||
await emailService.sendStudyInvitation({
|
||||
to: input.email,
|
||||
studyTitle: study.title,
|
||||
role: input.role,
|
||||
inviteUrl,
|
||||
});
|
||||
|
||||
return invitation;
|
||||
}),
|
||||
|
||||
updateMemberRole: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
userId: z.string(),
|
||||
role: z.enum([ROLES.ADMIN, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD]),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user is an admin
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only admins can update member roles",
|
||||
});
|
||||
}
|
||||
|
||||
// Get user details for activity log
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Update role and log activity
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(studyMembers)
|
||||
.set({ role: input.role })
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, input.userId),
|
||||
),
|
||||
);
|
||||
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "member_role_changed",
|
||||
description: `Updated ${user.firstName} ${user.lastName}'s role to ${input.role}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getMetadata: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user is a member of the study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to view study metadata",
|
||||
});
|
||||
}
|
||||
|
||||
return ctx.db.query.studyMetadata.findMany({
|
||||
where: eq(studyMetadata.studyId, input.studyId),
|
||||
orderBy: studyMetadata.key,
|
||||
});
|
||||
}),
|
||||
|
||||
addMetadata: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
key: z.string().min(1),
|
||||
value: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user is an admin
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only admins can add metadata",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if key already exists
|
||||
const existing = await ctx.db.query.studyMetadata.findFirst({
|
||||
where: and(
|
||||
eq(studyMetadata.studyId, input.studyId),
|
||||
eq(studyMetadata.key, input.key),
|
||||
),
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "A field with this name already exists",
|
||||
});
|
||||
}
|
||||
|
||||
// Add metadata and log activity
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx.insert(studyMetadata).values({
|
||||
studyId: input.studyId,
|
||||
key: input.key,
|
||||
value: input.value,
|
||||
});
|
||||
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "study_updated",
|
||||
description: `Added metadata field: ${input.key}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
deleteMetadata: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
key: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user is an admin
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role !== ROLES.ADMIN) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only admins can delete metadata",
|
||||
});
|
||||
}
|
||||
|
||||
// Delete metadata and log activity
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.delete(studyMetadata)
|
||||
.where(
|
||||
and(
|
||||
eq(studyMetadata.studyId, input.studyId),
|
||||
eq(studyMetadata.key, input.key),
|
||||
),
|
||||
);
|
||||
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "study_updated",
|
||||
description: `Deleted metadata field: ${input.key}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
getActivities: protectedProcedure
|
||||
.input(z.object({ studyId: z.number() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
// Check if user is a member of the study
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "You do not have permission to view study activities",
|
||||
});
|
||||
}
|
||||
|
||||
const activities = await ctx.db
|
||||
.select({
|
||||
id: studyActivities.id,
|
||||
type: studyActivities.type,
|
||||
description: studyActivities.description,
|
||||
userId: studyActivities.userId,
|
||||
userName: users.firstName,
|
||||
createdAt: studyActivities.createdAt,
|
||||
})
|
||||
.from(studyActivities)
|
||||
.innerJoin(users, eq(users.id, studyActivities.userId))
|
||||
.where(eq(studyActivities.studyId, input.studyId))
|
||||
.orderBy(desc(studyActivities.createdAt))
|
||||
.limit(50);
|
||||
|
||||
return activities;
|
||||
}),
|
||||
|
||||
getInvitation: publicProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.query(async ({ ctx, input }) => {
|
||||
const invitation = await db.query.studyInvitations.findFirst({
|
||||
where: eq(studyInvitations.token, input.token),
|
||||
with: {
|
||||
study: true,
|
||||
creator: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invitation not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invitation.status !== "pending") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
|
||||
if (new Date() > invitation.expiresAt) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
|
||||
return invitation;
|
||||
}),
|
||||
|
||||
acceptInvitation: protectedProcedure
|
||||
.input(z.object({ token: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const invitation = await ctx.db.query.studyInvitations.findFirst({
|
||||
where: eq(studyInvitations.token, input.token),
|
||||
});
|
||||
|
||||
if (!invitation) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Invitation not found",
|
||||
});
|
||||
}
|
||||
|
||||
if (invitation.status !== "pending") {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invitation has already been used",
|
||||
});
|
||||
}
|
||||
|
||||
if (new Date() > invitation.expiresAt) {
|
||||
// Log expired invitation
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(studyInvitations)
|
||||
.set({
|
||||
status: "expired",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(studyInvitations.id, invitation.id));
|
||||
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: invitation.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "invitation_expired",
|
||||
description: `Invitation for ${invitation.email} expired`,
|
||||
});
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "Invitation has expired",
|
||||
});
|
||||
}
|
||||
|
||||
if (ctx.session.user.email !== invitation.email) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "This invitation was sent to a different email address",
|
||||
});
|
||||
}
|
||||
|
||||
// Add the user to the study with the specified role
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
// Update invitation status
|
||||
await tx
|
||||
.update(studyInvitations)
|
||||
.set({
|
||||
status: "accepted",
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(studyInvitations.id, invitation.id));
|
||||
|
||||
// Add study membership
|
||||
await tx.insert(studyMembers).values({
|
||||
studyId: invitation.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
role: invitation.role,
|
||||
});
|
||||
|
||||
// Log invitation accepted activity
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: invitation.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "invitation_accepted",
|
||||
description: `Accepted invitation and joined study as ${invitation.role}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
transferOwnership: protectedProcedure
|
||||
.input(z.object({
|
||||
studyId: z.number(),
|
||||
newOwnerId: z.string(),
|
||||
}))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
// Check if user is the owner
|
||||
const membership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
});
|
||||
|
||||
if (!membership || membership.role.toLowerCase() !== ROLES.OWNER.toLowerCase()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only the owner can transfer ownership",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if new owner exists and is a member
|
||||
const newOwnerMembership = await ctx.db.query.studyMembers.findFirst({
|
||||
where: and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, input.newOwnerId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!newOwnerMembership) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "New owner must be a member of the study",
|
||||
});
|
||||
}
|
||||
|
||||
// Transfer ownership in a transaction
|
||||
await ctx.db.transaction(async (tx) => {
|
||||
// Change current owner to admin
|
||||
await tx
|
||||
.update(studyMembers)
|
||||
.set({ role: ROLES.ADMIN })
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
);
|
||||
|
||||
// Set new owner
|
||||
await tx
|
||||
.update(studyMembers)
|
||||
.set({ role: ROLES.OWNER })
|
||||
.where(
|
||||
and(
|
||||
eq(studyMembers.studyId, input.studyId),
|
||||
eq(studyMembers.userId, input.newOwnerId),
|
||||
),
|
||||
);
|
||||
|
||||
// Log activity
|
||||
await tx.insert(studyActivities).values({
|
||||
studyId: input.studyId,
|
||||
userId: ctx.session.user.id,
|
||||
type: "ownership_transferred",
|
||||
description: `Transferred study ownership to ${newOwnerMembership.userId}`,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
// Additional endpoints (like getOverview or getAll) can be added if needed.
|
||||
});
|
||||
@@ -16,6 +16,8 @@ declare module "next-auth" {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
name?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
@@ -59,7 +61,8 @@ export const authOptions: NextAuthOptions = {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? null,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
image: user.image ?? null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -26,13 +26,20 @@ declare module "@auth/core/types" {
|
||||
user: {
|
||||
id: string;
|
||||
email: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
// interface User {
|
||||
// // ...other properties
|
||||
// // role: UserRole;
|
||||
// }
|
||||
interface User {
|
||||
id?: string;
|
||||
email?: string | null;
|
||||
firstName?: string | null;
|
||||
lastName?: string | null;
|
||||
password?: string | null;
|
||||
emailVerified?: Date | null;
|
||||
image?: string | null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +49,7 @@ declare module "@auth/core/types" {
|
||||
*/
|
||||
export const authConfig = {
|
||||
adapter: DrizzleAdapter(db, {
|
||||
usersTable: users,
|
||||
usersTable: users as any,
|
||||
accountsTable: accounts,
|
||||
sessionsTable: sessions,
|
||||
verificationTokensTable: verificationTokens,
|
||||
@@ -89,7 +96,10 @@ export const authConfig = {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name ?? null,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
emailVerified: user.emailVerified,
|
||||
image: user.image,
|
||||
};
|
||||
}
|
||||
})
|
||||
@@ -100,10 +110,14 @@ export const authConfig = {
|
||||
user: {
|
||||
...session.user,
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : null,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
},
|
||||
}),
|
||||
},
|
||||
pages: {
|
||||
signIn: '/login',
|
||||
signIn: '/auth/signin',
|
||||
},
|
||||
} satisfies AuthConfig;
|
||||
|
||||
@@ -1,216 +1,4 @@
|
||||
import { relations, sql } from "drizzle-orm";
|
||||
import {
|
||||
index,
|
||||
integer,
|
||||
pgTableCreator,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { type AdapterAccount } from "next-auth/adapters";
|
||||
|
||||
/**
|
||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||
* database instance for multiple projects.
|
||||
*
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const createTable = pgTableCreator((name) => `hristudio_${name}`);
|
||||
|
||||
export const posts = createTable(
|
||||
"post",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
||||
name: varchar("name", { length: 256 }),
|
||||
createdById: varchar("created_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
|
||||
() => new Date()
|
||||
),
|
||||
},
|
||||
(example) => ({
|
||||
createdByIdIdx: index("created_by_idx").on(example.createdById),
|
||||
nameIndex: index("name_idx").on(example.name),
|
||||
})
|
||||
);
|
||||
|
||||
export const users = createTable("user", {
|
||||
id: varchar("id", { length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
firstName: varchar("first_name", { length: 255 }),
|
||||
lastName: varchar("last_name", { length: 255 }),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
password: varchar("password", { length: 255 }),
|
||||
emailVerified: timestamp("email_verified", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}).default(sql`CURRENT_TIMESTAMP`),
|
||||
image: varchar("image", { length: 255 }),
|
||||
});
|
||||
|
||||
export const studies = createTable(
|
||||
"study",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
createdById: varchar("created_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
|
||||
() => new Date()
|
||||
),
|
||||
},
|
||||
(study) => ({
|
||||
createdByIdIdx: index("study_created_by_idx").on(study.createdById),
|
||||
titleIndex: index("study_title_idx").on(study.title),
|
||||
})
|
||||
);
|
||||
|
||||
export const studyMembers = createTable(
|
||||
"study_member",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
||||
studyId: integer("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
role: varchar("role", { length: 50 }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
},
|
||||
(member) => ({
|
||||
studyUserIdx: index("study_member_study_user_idx").on(member.studyId, member.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const participants = createTable(
|
||||
"participant",
|
||||
{
|
||||
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
|
||||
studyId: integer("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id),
|
||||
identifier: varchar("identifier", { length: 256 }),
|
||||
email: varchar("email", { length: 256 }),
|
||||
firstName: varchar("first_name", { length: 256 }),
|
||||
lastName: varchar("last_name", { length: 256 }),
|
||||
notes: text("notes"),
|
||||
status: varchar("status", { length: 50 }).notNull().default("active"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
|
||||
() => new Date()
|
||||
),
|
||||
},
|
||||
(participant) => ({
|
||||
studyIdIdx: index("participant_study_id_idx").on(participant.studyId),
|
||||
identifierIdx: index("participant_identifier_idx").on(participant.identifier),
|
||||
emailIdx: index("participant_email_idx").on(participant.email),
|
||||
})
|
||||
);
|
||||
|
||||
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
|
||||
members: many(studyMembers),
|
||||
participants: many(participants),
|
||||
}));
|
||||
|
||||
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
||||
study: one(studies, { fields: [studyMembers.studyId], references: [studies.id] }),
|
||||
user: one(users, { fields: [studyMembers.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const participantsRelations = relations(participants, ({ one }) => ({
|
||||
study: one(studies, { fields: [participants.studyId], references: [studies.id] }),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
accounts: many(accounts),
|
||||
studies: many(studyMembers),
|
||||
}));
|
||||
|
||||
export const accounts = createTable(
|
||||
"account",
|
||||
{
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: varchar("type", { length: 255 })
|
||||
.$type<AdapterAccount["type"]>()
|
||||
.notNull(),
|
||||
provider: varchar("provider", { length: 255 }).notNull(),
|
||||
providerAccountId: varchar("provider_account_id", {
|
||||
length: 255,
|
||||
}).notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: integer("expires_at"),
|
||||
token_type: varchar("token_type", { length: 255 }),
|
||||
scope: varchar("scope", { length: 255 }),
|
||||
id_token: text("id_token"),
|
||||
session_state: varchar("session_state", { length: 255 }),
|
||||
},
|
||||
(account) => ({
|
||||
compoundKey: primaryKey({
|
||||
columns: [account.provider, account.providerAccountId],
|
||||
}),
|
||||
userIdIdx: index("account_user_id_idx").on(account.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const accountsRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, { fields: [accounts.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const sessions = createTable(
|
||||
"session",
|
||||
{
|
||||
sessionToken: varchar("session_token", { length: 255 })
|
||||
.notNull()
|
||||
.primaryKey(),
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
expires: timestamp("expires", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}).notNull(),
|
||||
},
|
||||
(session) => ({
|
||||
userIdIdx: index("session_user_id_idx").on(session.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const sessionsRelations = relations(sessions, ({ one }) => ({
|
||||
user: one(users, { fields: [sessions.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const verificationTokens = createTable(
|
||||
"verification_token",
|
||||
{
|
||||
identifier: varchar("identifier", { length: 255 }).notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull(),
|
||||
expires: timestamp("expires", {
|
||||
mode: "date",
|
||||
withTimezone: true,
|
||||
}).notNull(),
|
||||
},
|
||||
(vt) => ({
|
||||
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
|
||||
})
|
||||
);
|
||||
// Re-export all schema definitions from individual schema files
|
||||
export * from "./schema/auth";
|
||||
export * from "./schema/studies";
|
||||
export * from "./schema/permissions";
|
||||
@@ -1,10 +1,44 @@
|
||||
import { pgTable, varchar, timestamp } from "drizzle-orm/pg-core";
|
||||
import { text, timestamp, varchar, integer } from "drizzle-orm/pg-core";
|
||||
import { createTable } from "../utils";
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: varchar("id", { length: 255 }).primaryKey(),
|
||||
email: varchar("email", { length: 255 }).notNull().unique(),
|
||||
name: varchar("name", { length: 255 }),
|
||||
image: varchar("image", { length: 255 }),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
export const users = createTable("user", {
|
||||
id: varchar("id", { length: 255 }).notNull().primaryKey(),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
firstName: varchar("first_name", { length: 255 }),
|
||||
lastName: varchar("last_name", { length: 255 }),
|
||||
password: varchar("password", { length: 255 }),
|
||||
emailVerified: timestamp("emailVerified", { mode: "date" }),
|
||||
image: text("image"),
|
||||
});
|
||||
|
||||
export const accounts = createTable("account", {
|
||||
userId: varchar("userId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
type: varchar("type", { length: 255 })
|
||||
.$type<"oauth" | "oidc" | "email">()
|
||||
.notNull(),
|
||||
provider: varchar("provider", { length: 255 }).notNull(),
|
||||
providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(),
|
||||
refresh_token: text("refresh_token"),
|
||||
access_token: text("access_token"),
|
||||
expires_at: integer("expires_at"),
|
||||
token_type: varchar("token_type", { length: 255 }),
|
||||
scope: varchar("scope", { length: 255 }),
|
||||
id_token: text("id_token"),
|
||||
session_state: varchar("session_state", { length: 255 }),
|
||||
});
|
||||
|
||||
export const sessions = createTable("session", {
|
||||
sessionToken: varchar("sessionToken", { length: 255 }).notNull().primaryKey(),
|
||||
userId: varchar("userId", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||
});
|
||||
|
||||
export const verificationTokens = createTable("verificationToken", {
|
||||
identifier: varchar("identifier", { length: 255 }).notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull(),
|
||||
expires: timestamp("expires", { mode: "date" }).notNull(),
|
||||
});
|
||||
151
src/server/db/schema/experiments.ts
Normal file
151
src/server/db/schema/experiments.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
integer,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
serial
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { participants } from "../schema";
|
||||
import { users } from "./auth";
|
||||
import { studies } from "./studies";
|
||||
|
||||
// Enums
|
||||
export const experimentStatusEnum = pgEnum("experiment_status", [
|
||||
"draft",
|
||||
"active",
|
||||
"archived"
|
||||
]);
|
||||
|
||||
export const stepTypeEnum = pgEnum("step_type", [
|
||||
"instruction",
|
||||
"robot-action",
|
||||
"wizard-action"
|
||||
]);
|
||||
|
||||
export const actionTypeEnum = pgEnum("action_type", [
|
||||
"movement",
|
||||
"speech",
|
||||
"wait",
|
||||
"input"
|
||||
]);
|
||||
|
||||
export const trialStatusEnum = pgEnum("trial_status", [
|
||||
"pending",
|
||||
"in-progress",
|
||||
"completed",
|
||||
"cancelled"
|
||||
]);
|
||||
|
||||
// Tables
|
||||
export const experiments = pgTable("experiments", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id")
|
||||
.notNull()
|
||||
.references(() => studies.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
version: integer("version").notNull().default(1),
|
||||
status: varchar("status", { length: 50 })
|
||||
.notNull()
|
||||
.default("draft")
|
||||
.$type<typeof experimentStatusEnum.enumValues[number]>(),
|
||||
createdById: varchar("created_by", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const steps = pgTable("steps", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
experimentId: integer("experiment_id")
|
||||
.notNull()
|
||||
.references(() => experiments.id, { onDelete: "cascade" }),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
order: integer("order").notNull(),
|
||||
type: varchar("type", { length: 50 })
|
||||
.notNull()
|
||||
.$type<typeof stepTypeEnum.enumValues[number]>(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const actions = pgTable("actions", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
stepId: integer("step_id")
|
||||
.notNull()
|
||||
.references(() => steps.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 50 })
|
||||
.notNull()
|
||||
.$type<typeof actionTypeEnum.enumValues[number]>(),
|
||||
parameters: text("parameters"), // JSON string of action parameters
|
||||
order: integer("order").notNull(),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const trials = pgTable("trials", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
experimentId: integer("experiment_id")
|
||||
.notNull()
|
||||
.references(() => experiments.id, { onDelete: "cascade" }),
|
||||
participantId: integer("participant_id")
|
||||
.notNull()
|
||||
.references(() => participants.id, { onDelete: "cascade" }),
|
||||
wizardId: varchar("wizard_id", { length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
status: varchar("status", { length: 50 })
|
||||
.notNull()
|
||||
.default("pending")
|
||||
.$type<typeof trialStatusEnum.enumValues[number]>(),
|
||||
startedAt: timestamp("started_at"),
|
||||
completedAt: timestamp("completed_at"),
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const trialEvents = pgTable("trial_events", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
trialId: integer("trial_id")
|
||||
.notNull()
|
||||
.references(() => trials.id, { onDelete: "cascade" }),
|
||||
type: varchar("type", { length: 50 }).notNull(),
|
||||
actionId: integer("action_id").references(() => actions.id),
|
||||
data: text("data"), // JSON string of event data
|
||||
timestamp: timestamp("timestamp").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const experimentsRelations = relations(experiments, ({ one, many }) => ({
|
||||
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
|
||||
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
|
||||
steps: many(steps),
|
||||
trials: many(trials),
|
||||
}));
|
||||
|
||||
export const stepsRelations = relations(steps, ({ one, many }) => ({
|
||||
experiment: one(experiments, { fields: [steps.experimentId], references: [experiments.id] }),
|
||||
actions: many(actions),
|
||||
}));
|
||||
|
||||
export const actionsRelations = relations(actions, ({ one }) => ({
|
||||
step: one(steps, { fields: [actions.stepId], references: [steps.id] }),
|
||||
}));
|
||||
|
||||
export const trialsRelations = relations(trials, ({ one, many }) => ({
|
||||
experiment: one(experiments, { fields: [trials.experimentId], references: [experiments.id] }),
|
||||
participant: one(participants, { fields: [trials.participantId], references: [participants.id] }),
|
||||
wizard: one(users, { fields: [trials.wizardId], references: [users.id] }),
|
||||
events: many(trialEvents),
|
||||
}));
|
||||
|
||||
export const trialEventsRelations = relations(trialEvents, ({ one }) => ({
|
||||
trial: one(trials, { fields: [trialEvents.trialId], references: [trials.id] }),
|
||||
action: one(actions, { fields: [trialEvents.actionId], references: [actions.id] }),
|
||||
}));
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./auth";
|
||||
export * from "./studies";
|
||||
export * from "./permissions";
|
||||
export * from "./permissions";
|
||||
export * from "./experiments";
|
||||
@@ -1,17 +1,19 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
integer,
|
||||
pgTable,
|
||||
primaryKey,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
serial,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { users } from "~/server/db/schema/auth";
|
||||
import { studies } from "~/server/db/schema/studies";
|
||||
import { users } from "./auth";
|
||||
import { studies } from "./studies";
|
||||
|
||||
export const permissions = pgTable("permissions", {
|
||||
id: integer("id").notNull().primaryKey(),
|
||||
import { createTable } from "../utils";
|
||||
|
||||
export const permissions = createTable("permissions", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
code: varchar("code", { length: 50 }).notNull().unique(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
description: text("description"),
|
||||
@@ -19,8 +21,8 @@ export const permissions = pgTable("permissions", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const roles = pgTable("roles", {
|
||||
id: integer("id").notNull().primaryKey(),
|
||||
export const roles = createTable("roles", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
code: varchar("code", { length: 50 }).notNull().unique(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
description: text("description"),
|
||||
@@ -28,7 +30,7 @@ export const roles = pgTable("roles", {
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const rolePermissions = pgTable(
|
||||
export const rolePermissions = createTable(
|
||||
"role_permissions",
|
||||
{
|
||||
roleId: integer("role_id")
|
||||
@@ -44,7 +46,7 @@ export const rolePermissions = pgTable(
|
||||
})
|
||||
);
|
||||
|
||||
export const userRoles = pgTable(
|
||||
export const userRoles = createTable(
|
||||
"user_roles",
|
||||
{
|
||||
userId: varchar("user_id", { length: 255 })
|
||||
|
||||
@@ -1,20 +1,159 @@
|
||||
import { pgTable, integer, varchar, text, timestamp } from "drizzle-orm/pg-core";
|
||||
import { createEnum } from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { integer, pgEnum, text, timestamp, varchar, serial } from "drizzle-orm/pg-core";
|
||||
import { ROLES } from "~/lib/permissions/constants";
|
||||
import { createTable } from "../utils";
|
||||
import { users } from "./auth";
|
||||
|
||||
// Create enum from role values, excluding PRINCIPAL_INVESTIGATOR and ASSISTANT
|
||||
// which are handled through permissions
|
||||
export const studyRoleEnum = createEnum("study_role", [
|
||||
// Create enum from role values
|
||||
export const studyRoleEnum = pgEnum("study_role", [
|
||||
ROLES.OWNER,
|
||||
ROLES.ADMIN,
|
||||
ROLES.RESEARCHER,
|
||||
ROLES.PRINCIPAL_INVESTIGATOR,
|
||||
ROLES.WIZARD,
|
||||
ROLES.RESEARCHER,
|
||||
ROLES.OBSERVER,
|
||||
]);
|
||||
|
||||
export const studies = pgTable("studies", {
|
||||
id: integer("id").primaryKey(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
// Create enum for participant status
|
||||
export const participantStatusEnum = pgEnum("participant_status", [
|
||||
"active",
|
||||
"inactive",
|
||||
"completed",
|
||||
"withdrawn",
|
||||
]);
|
||||
|
||||
// Create enum for activity types
|
||||
export const activityTypeEnum = pgEnum("activity_type", [
|
||||
"study_created",
|
||||
"study_updated",
|
||||
"study_deleted",
|
||||
"ownership_transferred",
|
||||
"member_added",
|
||||
"member_removed",
|
||||
"member_role_changed",
|
||||
"participant_added",
|
||||
"participant_updated",
|
||||
"participant_removed",
|
||||
"experiment_created",
|
||||
"experiment_updated",
|
||||
"experiment_deleted",
|
||||
"trial_started",
|
||||
"trial_completed",
|
||||
"trial_cancelled",
|
||||
"invitation_sent",
|
||||
"invitation_accepted",
|
||||
"invitation_declined",
|
||||
"invitation_expired",
|
||||
"invitation_revoked",
|
||||
"consent_form_added",
|
||||
"consent_form_signed",
|
||||
"metadata_updated",
|
||||
"data_exported",
|
||||
]);
|
||||
|
||||
// Create enum for invitation status
|
||||
export const invitationStatusEnum = pgEnum("invitation_status", [
|
||||
"pending",
|
||||
"accepted",
|
||||
"declined",
|
||||
"expired",
|
||||
"revoked",
|
||||
]);
|
||||
|
||||
export const studyActivityTypeEnum = pgEnum("study_activity_type", [
|
||||
"member_added",
|
||||
"member_role_changed",
|
||||
"study_updated",
|
||||
"participant_added",
|
||||
"participant_updated",
|
||||
"invitation_sent",
|
||||
"invitation_accepted",
|
||||
"invitation_declined",
|
||||
"invitation_expired",
|
||||
"invitation_revoked",
|
||||
]);
|
||||
|
||||
export const studies = createTable("study", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
title: varchar("title", { length: 256 }).notNull(),
|
||||
description: text("description"),
|
||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
||||
});
|
||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const studyMembers = createTable("study_member", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
role: studyRoleEnum("role").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const studyMetadata = createTable("study_metadata", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
key: varchar("key", { length: 256 }).notNull(),
|
||||
value: text("value"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const studyActivities = createTable("study_activity", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id),
|
||||
type: activityTypeEnum("type").notNull(),
|
||||
description: text("description").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
export const participants = createTable("participant", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
// Identifiable information - only visible to roles with VIEW_PARTICIPANT_NAMES permission
|
||||
identifier: varchar("identifier", { length: 256 }),
|
||||
email: varchar("email", { length: 256 }),
|
||||
firstName: varchar("first_name", { length: 256 }),
|
||||
lastName: varchar("last_name", { length: 256 }),
|
||||
// Non-identifiable information - visible to all study members
|
||||
notes: text("notes"),
|
||||
status: participantStatusEnum("status").notNull().default("active"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
});
|
||||
|
||||
export const studyInvitations = createTable("study_invitation", {
|
||||
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
|
||||
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
|
||||
email: varchar("email", { length: 255 }).notNull(),
|
||||
role: studyRoleEnum("role").notNull(),
|
||||
token: varchar("token", { length: 255 }).notNull().unique(),
|
||||
status: invitationStatusEnum("status").notNull().default("pending"),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }),
|
||||
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
|
||||
});
|
||||
|
||||
// Relations
|
||||
export const studiesRelations = relations(studies, ({ one, many }) => ({
|
||||
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
|
||||
members: many(studyMembers),
|
||||
participants: many(participants),
|
||||
invitations: many(studyInvitations),
|
||||
}));
|
||||
|
||||
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
|
||||
study: one(studies, { fields: [studyMembers.studyId], references: [studies.id] }),
|
||||
user: one(users, { fields: [studyMembers.userId], references: [users.id] }),
|
||||
}));
|
||||
|
||||
export const participantsRelations = relations(participants, ({ one }) => ({
|
||||
study: one(studies, { fields: [participants.studyId], references: [studies.id] }),
|
||||
}));
|
||||
|
||||
export const studyInvitationsRelations = relations(studyInvitations, ({ one }) => ({
|
||||
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
|
||||
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
|
||||
}));
|
||||
7
src/server/db/utils.ts
Normal file
7
src/server/db/utils.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { pgTableCreator } from "drizzle-orm/pg-core";
|
||||
|
||||
/**
|
||||
* This creates tables with the given prefix to avoid naming conflicts in the database
|
||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
||||
*/
|
||||
export const createTable = pgTableCreator((name) => `hs_${name}`);
|
||||
42
src/server/email/config.ts
Normal file
42
src/server/email/config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env";
|
||||
|
||||
export const emailConfigSchema = z.object({
|
||||
smtp: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
secure: z.boolean().default(false),
|
||||
auth: z.object({
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
}),
|
||||
tls: z.object({
|
||||
rejectUnauthorized: z.boolean().default(true),
|
||||
}).default({}),
|
||||
}),
|
||||
from: z.object({
|
||||
name: z.string(),
|
||||
email: z.string().email(),
|
||||
}),
|
||||
});
|
||||
|
||||
export type EmailConfig = z.infer<typeof emailConfigSchema>;
|
||||
|
||||
export const emailConfig = {
|
||||
smtp: {
|
||||
host: env.SMTP_HOST,
|
||||
port: Number(env.SMTP_PORT),
|
||||
secure: false,
|
||||
auth: {
|
||||
user: env.SMTP_USER,
|
||||
pass: env.SMTP_PASS,
|
||||
},
|
||||
tls: {
|
||||
rejectUnauthorized: true,
|
||||
},
|
||||
},
|
||||
from: {
|
||||
name: env.EMAIL_FROM_NAME,
|
||||
email: env.EMAIL_FROM_ADDRESS,
|
||||
},
|
||||
} satisfies EmailConfig;
|
||||
86
src/server/email/service.ts
Normal file
86
src/server/email/service.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { type EmailConfig, emailConfig } from "./config";
|
||||
|
||||
export class EmailService {
|
||||
private transporter: nodemailer.Transporter;
|
||||
private config: EmailConfig;
|
||||
|
||||
constructor(config: EmailConfig = emailConfig) {
|
||||
this.config = config;
|
||||
this.transporter = nodemailer.createTransport(config.smtp);
|
||||
}
|
||||
|
||||
async sendMail(options: {
|
||||
to: string;
|
||||
subject: string;
|
||||
text?: string;
|
||||
html?: string;
|
||||
}) {
|
||||
const { to, subject, text, html } = options;
|
||||
|
||||
await this.transporter.sendMail({
|
||||
from: `"${this.config.from.name}" <${this.config.from.email}>`,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
}
|
||||
|
||||
async sendStudyInvitation({
|
||||
to,
|
||||
studyTitle,
|
||||
role,
|
||||
inviteUrl,
|
||||
}: {
|
||||
to: string;
|
||||
studyTitle: string;
|
||||
role: string;
|
||||
inviteUrl: string;
|
||||
}) {
|
||||
const subject = `Invitation to join "${studyTitle}" as ${role}`;
|
||||
const html = `
|
||||
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2>You've been invited!</h2>
|
||||
<p>You've been invited to join the study "${studyTitle}" as a ${role}.</p>
|
||||
<p style="margin: 24px 0;">
|
||||
<a href="${inviteUrl}"
|
||||
style="background: #0091FF; color: white; padding: 12px 24px;
|
||||
text-decoration: none; border-radius: 6px; display: inline-block;">
|
||||
Accept Invitation
|
||||
</a>
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
If you can't click the button above, copy and paste this URL into your browser:<br>
|
||||
${inviteUrl}
|
||||
</p>
|
||||
<p style="color: #666; font-size: 14px;">
|
||||
This invitation will expire in 7 days.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const text = `
|
||||
You've been invited!
|
||||
|
||||
You've been invited to join the study "${studyTitle}" as a ${role}.
|
||||
|
||||
To accept the invitation, visit this URL:
|
||||
${inviteUrl}
|
||||
|
||||
This invitation will expire in 7 days.
|
||||
`.trim();
|
||||
|
||||
await this.sendMail({ to, subject, html, text });
|
||||
}
|
||||
|
||||
async verifyConnection() {
|
||||
try {
|
||||
await this.transporter.verify();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to verify email connection:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ const defaultQueryClientOptions = {
|
||||
onError: (error: unknown) => {
|
||||
const err = error as { message?: string };
|
||||
// Ignore unauthorized errors on public pages
|
||||
if (err?.message === "UNAUTHORIZED" && typeof window !== "undefined" && window.location.pathname.match(/^\/(login|register|$)/)) {
|
||||
if (err?.message === "UNAUTHORIZED" && typeof window !== "undefined" && window.location.pathname.match(/^\/(auth\/signin|auth\/signup|$)/)) {
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
122
structure.md
122
structure.md
@@ -14,18 +14,124 @@ An *action* is a specific operation that is being done (like "move to position",
|
||||
|
||||
A *participant* is a person that has been added to a study. This person does not have an account.
|
||||
|
||||
A *user* is a person that has an account, which is a person that has been added to a study. Anyone can sign up for an account, but they must be added to a study or create their own. A user can have one of many roles, but can only have one role at a time. They can be in one or more studies, allowing them to have different roles in different studies.
|
||||
A *user* is a person that has an account, which is a person that has been added to a study. Anyone can sign up for an account, but they must be added to a study or create their own. A user can have different roles in different studies.
|
||||
|
||||
A *role* is a set of permissions that a user has in a study. A user can have one or more roles, but can only have one role at a time per study.
|
||||
## Roles and Permissions
|
||||
|
||||
A *permission* is a specific action that a user can perform in a study. Permissions are grouped into categories, and each category has a set of permissions.
|
||||
### Core Roles
|
||||
|
||||
Roles and permissions:
|
||||
1. **Owner**
|
||||
- Single owner per study
|
||||
- Full control over all aspects of the study
|
||||
- Can delete study or transfer ownership
|
||||
- Can manage all other roles
|
||||
- Usually the study creator or designated successor
|
||||
- Cannot be removed except through ownership transfer
|
||||
|
||||
An admin is a user with all permissions in a study. This is usually the creator of the study, but may not be the principal investigator.
|
||||
The principal investigator is the PI of the project- may not be the creator of the study on the platform
|
||||
A wizard is a user that can run experiment trials, and can view the results of the trials.
|
||||
A researcher is a user that can view the results of the trials, and interpret the data.
|
||||
2. **Admin**
|
||||
- Multiple admins allowed
|
||||
- Can manage participants, experiments, and study settings
|
||||
- Can invite and manage other users (except Owner)
|
||||
- Cannot delete study or transfer ownership
|
||||
- Appointed by Owner
|
||||
|
||||
3. **Principal Investigator (PI)**
|
||||
- Scientific oversight role
|
||||
- Full access to participant data and experiment design
|
||||
- Can manage experiment protocols
|
||||
- Can analyze and export all data
|
||||
- Cannot modify core study settings or manage user roles
|
||||
- Typically one PI per study
|
||||
|
||||
4. **Wizard**
|
||||
- Operates the robot during experiment trials
|
||||
- Can control live experiment sessions
|
||||
- Can view anonymized participant data
|
||||
- Can annotate experiments in real-time
|
||||
- Cannot modify study design or access sensitive participant data
|
||||
- Multiple wizards allowed
|
||||
|
||||
5. **Researcher**
|
||||
- Can view and analyze experiment data
|
||||
- Can access anonymized participant information
|
||||
- Can export and analyze results
|
||||
- Cannot modify study design or participant data
|
||||
- Cannot run experiment trials
|
||||
- Multiple researchers allowed
|
||||
|
||||
6. **Observer**
|
||||
- Can view live experiments
|
||||
- Can view anonymized participant data
|
||||
- Can add annotations
|
||||
- Cannot modify any study aspects
|
||||
- Cannot access sensitive data
|
||||
- Multiple observers allowed
|
||||
|
||||
### Permission Categories
|
||||
|
||||
1. **Study Management**
|
||||
- Create/Delete Study (Owner only)
|
||||
- Edit Study Settings
|
||||
- Transfer Ownership (Owner only)
|
||||
- Manage Study Metadata
|
||||
|
||||
2. **Participant Management**
|
||||
- Add/Remove Participants
|
||||
- View Participant Details (identifiable vs. anonymized)
|
||||
- Edit Participant Information
|
||||
- Manage Participant Consent Forms
|
||||
|
||||
3. **Experiment Design**
|
||||
- Create/Edit Experiment Templates
|
||||
- Define Steps and Actions
|
||||
- Set Robot Behaviors
|
||||
- Configure Data Collection
|
||||
|
||||
4. **Experiment Execution**
|
||||
- Run Experiment Trials
|
||||
- Control Robot Actions
|
||||
- Monitor Live Sessions
|
||||
- Add Real-time Annotations
|
||||
|
||||
5. **Data Access**
|
||||
- View Raw Data
|
||||
- View Anonymized Data
|
||||
- Export Data
|
||||
- Access Participant Identifiable Information
|
||||
|
||||
6. **User Management**
|
||||
- Invite Users
|
||||
- Assign Roles
|
||||
- Remove Users
|
||||
- Manage Permissions
|
||||
|
||||
### Role-Permission Matrix
|
||||
|
||||
| Permission Category | Owner | Admin | PI | Wizard | Researcher | Observer |
|
||||
|-----------------------|-------|-------|-----|--------|------------|----------|
|
||||
| Study Management | Full | Most | No | No | No | No |
|
||||
| Participant Management| Full | Full | Full| Limited| Limited | View Only|
|
||||
| Experiment Design | Full | Full | Full| No | No | No |
|
||||
| Experiment Execution | Full | Full | Full| Full | View Only | View Only|
|
||||
| Data Access | Full | Full | Full| Limited| Limited | Limited |
|
||||
| User Management | Full | Most | No | No | No | No |
|
||||
|
||||
### Special Considerations
|
||||
|
||||
1. **Data Privacy**
|
||||
- Identifiable participant information is only accessible to Owner, Admin, and PI roles
|
||||
- All other roles see anonymized data
|
||||
- Audit logs track all data access
|
||||
|
||||
2. **Role Hierarchy**
|
||||
- Owner > Admin > PI > Wizard/Researcher > Observer
|
||||
- Higher roles inherit permissions from lower roles
|
||||
- Certain permissions (like study deletion) are restricted to specific roles
|
||||
|
||||
3. **Role Assignment**
|
||||
- Users can have different roles in different studies
|
||||
- One user cannot hold multiple roles in the same study
|
||||
- Role changes are logged and require appropriate permissions
|
||||
|
||||
Participant Management: can create, update, delete participants, as well as view their personal information
|
||||
- Admin: can do everything
|
||||
|
||||
Reference in New Issue
Block a user