From 3ec8b2fe463083049cbe3cb4809c0745b2b01f7c Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Tue, 3 Dec 2024 23:02:23 -0500 Subject: [PATCH] feat(env): Update environment configuration and enhance email functionality - Renamed DATABASE_URL to POSTGRES_URL in .env.example for clarity. - Added SMTP configuration for email sending, including host, port, user, password, and from address. - Updated package.json to include new dependencies for email handling and UI components. - Modified middleware to handle public and protected routes more effectively. - Enhanced API routes for studies to support user roles and permissions. - Updated database schema to include invitations and user roles related to studies. - Improved user permissions handling in the permissions module. - Added new utility functions for managing user roles and study access. --- .env.example | 9 +- package.json | 10 +- pnpm-lock.yaml | 119 +++++++++++ src/app/api/invitations/[id]/route.ts | 50 +++++ .../api/invitations/accept/[token]/route.ts | 64 ++++++ src/app/api/invitations/route.ts | 160 ++++++++++++++ src/app/api/roles/route.ts | 27 +++ src/app/api/studies/[id]/route.ts | 45 ++++ src/app/api/studies/route.ts | 45 ++-- src/app/api/webhooks/clerk/route.ts | 106 +++------- .../dashboard/studies/[id]/settings/page.tsx | 197 ++++++++++++++++++ src/app/dashboard/studies/page.tsx | 20 +- .../[token]/invitation-accept-content.tsx | 142 +++++++++++++ src/app/invite/accept/[token]/page.tsx | 23 ++ src/app/sign-in/[[...sign-in]]/page.tsx | 12 ++ src/app/sign-up/[[...sign-up]]/page.tsx | 12 ++ src/components/invite-user-dialog.tsx | 157 ++++++++++++++ src/components/ui/alert-dialog.tsx | 141 +++++++++++++ src/components/ui/alert.tsx | 59 ++++++ src/components/ui/badge.tsx | 36 ++++ src/components/ui/dialog.tsx | 121 +++++++++++ src/components/ui/tabs.tsx | 55 +++++ src/db/schema.ts | 53 ++++- src/db/seed.ts | 28 ++- src/lib/email.ts | 76 +++++++ src/lib/permissions.ts | 71 ++++++- src/middleware.ts | 26 ++- src/scripts/test-email.ts | 32 +++ 28 files changed, 1775 insertions(+), 121 deletions(-) create mode 100644 src/app/api/invitations/[id]/route.ts create mode 100644 src/app/api/invitations/accept/[token]/route.ts create mode 100644 src/app/api/invitations/route.ts create mode 100644 src/app/api/roles/route.ts create mode 100644 src/app/api/studies/[id]/route.ts create mode 100644 src/app/dashboard/studies/[id]/settings/page.tsx create mode 100644 src/app/invite/accept/[token]/invitation-accept-content.tsx create mode 100644 src/app/invite/accept/[token]/page.tsx create mode 100644 src/app/sign-in/[[...sign-in]]/page.tsx create mode 100644 src/app/sign-up/[[...sign-up]]/page.tsx create mode 100644 src/components/invite-user-dialog.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx create mode 100644 src/components/ui/badge.tsx create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/components/ui/tabs.tsx create mode 100644 src/lib/email.ts create mode 100644 src/scripts/test-email.ts diff --git a/.env.example b/.env.example index 4167965..4141a3a 100644 --- a/.env.example +++ b/.env.example @@ -3,11 +3,18 @@ NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ CLERK_SECRET_KEY=sk_test_ # Database -DATABASE_URL="postgresql://user:password@localhost:5432/dbname" +POSTGRES_URL="postgresql://user:password@localhost:5432/dbname" # Next.js NEXT_PUBLIC_APP_URL="http://localhost:3000" +# Email (SMTP) +SMTP_HOST=smtp.mail.me.com +SMTP_PORT=587 +SMTP_USER=your-email@example.com +SMTP_PASSWORD=your-app-specific-password +SMTP_FROM_ADDRESS=noreply@yourdomain.com + # Optional: For production deployments # NEXT_PUBLIC_APP_URL="https://yourdomain.com" # VERCEL_URL="https://yourdomain.com" \ No newline at end of file diff --git a/package.json b/package.json index 6809f30..20668f0 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", @@ -12,23 +12,29 @@ "db:seed": "tsx src/db/seed.ts", "ngrok:start": "ngrok http --url=endless-pegasus-happily.ngrok-free.app 3000", "db:drop": "tsx src/db/drop.ts", - "db:reset": "pnpm db:drop && pnpm db:push && pnpm db:seed" + "db:reset": "pnpm db:drop && pnpm db:push && pnpm db:seed", + "test:email": "tsx src/scripts/test-email.ts" }, "dependencies": { "@clerk/nextjs": "^6.4.0", + "@radix-ui/react-alert-dialog": "^1.1.2", "@radix-ui/react-dialog": "^1.1.2", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tabs": "^1.1.1", + "@types/nodemailer": "^6.4.17", "@vercel/postgres": "^0.10.0", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dotenv": "^16.4.5", "drizzle-orm": "^0.36.3", "lucide-react": "^0.454.0", "next": "15.0.2", "ngrok": "5.0.0-beta.2", + "nodemailer": "^6.9.16", "react": "^18.3.1", "react-dom": "^18.3.1", "svix": "^1.41.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6dd6d0d..edb5a08 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@clerk/nextjs': specifier: ^6.4.0 version: 6.4.0(next@15.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-alert-dialog': + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@radix-ui/react-dialog': specifier: ^1.1.2 version: 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -26,6 +29,12 @@ importers: '@radix-ui/react-slot': specifier: ^1.1.0 version: 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-tabs': + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/nodemailer': + specifier: ^6.4.17 + version: 6.4.17 '@vercel/postgres': specifier: ^0.10.0 version: 0.10.0 @@ -35,6 +44,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 dotenv: specifier: ^16.4.5 version: 16.4.5 @@ -50,6 +62,9 @@ importers: ngrok: specifier: 5.0.0-beta.2 version: 5.0.0-beta.2 + nodemailer: + specifier: ^6.9.16 + version: 6.9.16 react: specifier: ^18.3.1 version: 18.3.1 @@ -845,6 +860,19 @@ packages: '@radix-ui/primitive@1.1.0': resolution: {integrity: sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==} + '@radix-ui/react-alert-dialog@1.1.2': + resolution: {integrity: sha512-eGSlLzPhKO+TErxkiGcCZGuvbVMnLA1MTnyBksGOeGRGkxHiiJUujsjmNTdWTm4iHVSRaUao9/4Ur671auMghQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-arrow@1.1.0': resolution: {integrity: sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==} peerDependencies: @@ -1034,6 +1062,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-roving-focus@1.1.0': + resolution: {integrity: sha512-EA6AMGeq9AEeQDeSH0aZgG198qkfHSbvWTf1HvoDmOB5bBG/qTxjYMWUKMnYiV6J/iP/J8MEFSuB2zRU2n7ODA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-select@2.1.2': resolution: {integrity: sha512-rZJtWmorC7dFRi0owDmoijm6nSJH1tVw64QGiNIZ9PNLyBDtG+iAq+XGsya052At4BfarzY/Dhv9wrrUr6IMZA==} peerDependencies: @@ -1056,6 +1097,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-tabs@1.1.1': + resolution: {integrity: sha512-3GBUDmP2DvzmtYLMsHmpA1GtR46ZDZ+OreXM/N+kkQJOPIgytFWWTfDQmBQKBvaFS0Vno0FktdbVzN28KGrMdw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -1179,6 +1233,9 @@ packages: '@types/node@22.9.1': resolution: {integrity: sha512-p8Yy/8sw1caA8CdRIQBG5tiLHmxtQKObCijiAa9Ez+d4+PRffM4054xbju0msf+cvhJpnFEeNjxmVT/0ipktrg==} + '@types/nodemailer@6.4.17': + resolution: {integrity: sha512-I9CCaIp6DTldEg7vyUTZi8+9Vo0hi1/T8gv3C89yk1rSAAzoKQ8H8ki/jBYJSFoH/BisgLP8tkZMlQ91CIquww==} + '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} @@ -1497,6 +1554,9 @@ packages: resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} engines: {node: '>= 0.4'} + date-fns@4.1.0: + resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} + debug@3.2.7: resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} peerDependencies: @@ -2334,6 +2394,10 @@ packages: resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} hasBin: true + nodemailer@6.9.16: + resolution: {integrity: sha512-psAuZdTIRN08HKVd/E8ObdV6NO7NTBY3KsC30F7M4H1OnmLCUNaS56FpYxyb26zWLSyYF9Ozch9KYHhHegsiOQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -3494,6 +3558,20 @@ snapshots: '@radix-ui/primitive@1.1.0': {} + '@radix-ui/react-alert-dialog@1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-dialog': 1.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-slot': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-arrow@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3658,6 +3736,23 @@ snapshots: '@types/react': 18.3.12 '@types/react-dom': 18.3.1 + '@radix-ui/react-roving-focus@1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-collection': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-compose-refs': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-context': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-select@2.1.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/number': 1.1.0 @@ -3694,6 +3789,22 @@ snapshots: optionalDependencies: '@types/react': 18.3.12 + '@radix-ui/react-tabs@1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@radix-ui/primitive': 1.1.0 + '@radix-ui/react-context': 1.1.1(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-direction': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-id': 1.1.0(@types/react@18.3.12)(react@18.3.1) + '@radix-ui/react-presence': 1.1.1(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-primitive': 2.0.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-roving-focus': 1.1.0(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@18.3.12)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@18.3.12)(react@18.3.1)': dependencies: react: 18.3.1 @@ -3792,6 +3903,10 @@ snapshots: dependencies: undici-types: 6.19.8 + '@types/nodemailer@6.4.17': + dependencies: + '@types/node': 22.9.1 + '@types/pg@8.11.6': dependencies: '@types/node': 22.9.1 @@ -4167,6 +4282,8 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.1 + date-fns@4.1.0: {} + debug@3.2.7: dependencies: ms: 2.1.3 @@ -5119,6 +5236,8 @@ snapshots: node-gyp-build@4.8.4: {} + nodemailer@6.9.16: {} + normalize-path@3.0.0: {} normalize-url@6.1.0: {} diff --git a/src/app/api/invitations/[id]/route.ts b/src/app/api/invitations/[id]/route.ts new file mode 100644 index 0000000..1a42e34 --- /dev/null +++ b/src/app/api/invitations/[id]/route.ts @@ -0,0 +1,50 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "~/db"; +import { invitationsTable } from "~/db/schema"; +import { hasPermission, PERMISSIONS } from "~/lib/permissions"; + +export async function DELETE( + request: Request, + context: { params: { id: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + // Properly await and destructure params + const { id } = await context.params; + const invitationId = parseInt(id); + + // Get the invitation to check study access + const [invitation] = await db + .select() + .from(invitationsTable) + .where(eq(invitationsTable.id, invitationId)) + .limit(1); + + if (!invitation) { + return new NextResponse("Invitation not found", { status: 404 }); + } + + // Check if user has permission to manage roles for this study + const canManageRoles = await hasPermission(userId, PERMISSIONS.MANAGE_ROLES, invitation.studyId); + if (!canManageRoles) { + return new NextResponse("Forbidden", { status: 403 }); + } + + // Delete the invitation + await db + .delete(invitationsTable) + .where(eq(invitationsTable.id, invitationId)); + + return new NextResponse(null, { status: 204 }); + } catch (error) { + console.error("Error deleting invitation:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/invitations/accept/[token]/route.ts b/src/app/api/invitations/accept/[token]/route.ts new file mode 100644 index 0000000..9bdac2e --- /dev/null +++ b/src/app/api/invitations/accept/[token]/route.ts @@ -0,0 +1,64 @@ +import { eq, and, gt } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "~/db"; +import { invitationsTable, userRolesTable } from "~/db/schema"; + +export async function POST( + request: Request, + { params }: { params: { token: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + const { token } = params; + + // Find the invitation + const [invitation] = await db + .select() + .from(invitationsTable) + .where( + and( + eq(invitationsTable.token, token), + eq(invitationsTable.accepted, false), + gt(invitationsTable.expiresAt, new Date()) + ) + ) + .limit(1); + + if (!invitation) { + return new NextResponse( + "Invitation not found or has expired", + { status: 404 } + ); + } + + // Start a transaction + await db.transaction(async (tx) => { + // Mark invitation as accepted + await tx + .update(invitationsTable) + .set({ accepted: true }) + .where(eq(invitationsTable.id, invitation.id)); + + // Assign role to user for this specific study + await tx + .insert(userRolesTable) + .values({ + userId, + roleId: invitation.roleId, + studyId: invitation.studyId, + }) + .onConflictDoNothing(); + }); + + return new NextResponse("Invitation accepted", { status: 200 }); + } catch (error) { + console.error("Error accepting invitation:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/invitations/route.ts b/src/app/api/invitations/route.ts new file mode 100644 index 0000000..feb884a --- /dev/null +++ b/src/app/api/invitations/route.ts @@ -0,0 +1,160 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "~/db"; +import { invitationsTable, studyTable, rolesTable, usersTable } from "~/db/schema"; +import { eq, and } from "drizzle-orm"; +import { randomBytes } from "crypto"; +import { sendInvitationEmail } from "~/lib/email"; +import { hasPermission, hasStudyAccess, PERMISSIONS } from "~/lib/permissions"; + +// Helper to generate a secure random token +function generateToken(): string { + return randomBytes(32).toString('hex'); +} + +export async function POST(request: Request) { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + const { email, studyId, roleId } = await request.json(); + console.log("Invitation request:", { email, studyId, roleId }); + + // First check if user has access to the study + const hasAccess = await hasStudyAccess(userId, studyId); + console.log("Study access check:", { userId, studyId, hasAccess }); + if (!hasAccess) { + return new NextResponse("Study not found", { status: 404 }); + } + + // Then check if user has permission to invite users + const canInvite = await hasPermission(userId, PERMISSIONS.MANAGE_ROLES, studyId); + console.log("Permission check:", { userId, studyId, canInvite }); + if (!canInvite) { + return new NextResponse("Forbidden", { status: 403 }); + } + + // Get study details + const study = await db + .select() + .from(studyTable) + .where(eq(studyTable.id, studyId)) + .limit(1); + + if (!study[0]) { + return new NextResponse("Study not found", { status: 404 }); + } + + // Verify the role exists + const role = await db + .select() + .from(rolesTable) + .where(eq(rolesTable.id, roleId)) + .limit(1); + + if (!role[0]) { + return new NextResponse("Role not found", { status: 404 }); + } + + // Get inviter's name + const inviter = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, userId)) + .limit(1); + + if (!inviter[0]) { + return new NextResponse("Inviter not found", { status: 404 }); + } + + // Generate invitation token + const token = generateToken(); + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 7); // Expires in 7 days + + // Create invitation + const [invitation] = await db + .insert(invitationsTable) + .values({ + email, + studyId, + roleId, + token, + invitedById: userId, + expiresAt, + }) + .returning(); + + // Send invitation email + await sendInvitationEmail({ + to: email, + inviterName: inviter[0].name || "A researcher", + studyTitle: study[0].title, + role: role[0].name, + token, + }); + + return NextResponse.json(invitation); + } catch (error) { + console.error("Error creating invitation:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} + +export async function GET(request: Request) { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + const url = new URL(request.url); + const studyId = url.searchParams.get("studyId"); + + if (!studyId) { + return new NextResponse("Study ID is required", { status: 400 }); + } + + // First check if user has access to the study + const hasAccess = await hasStudyAccess(userId, parseInt(studyId)); + if (!hasAccess) { + return new NextResponse("Study not found", { status: 404 }); + } + + // Get study details + const study = await db + .select() + .from(studyTable) + .where(eq(studyTable.id, parseInt(studyId))) + .limit(1); + + if (!study[0]) { + return new NextResponse("Study not found", { status: 404 }); + } + + // Get all invitations for the study + const invitations = await db + .select({ + id: invitationsTable.id, + email: invitationsTable.email, + accepted: invitationsTable.accepted, + expiresAt: invitationsTable.expiresAt, + createdAt: invitationsTable.createdAt, + roleName: rolesTable.name, + inviterName: usersTable.name, + }) + .from(invitationsTable) + .innerJoin(rolesTable, eq(invitationsTable.roleId, rolesTable.id)) + .innerJoin(usersTable, eq(invitationsTable.invitedById, usersTable.id)) + .where(eq(invitationsTable.studyId, parseInt(studyId))); + + return NextResponse.json(invitations); + } catch (error) { + console.error("Error fetching invitations:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/roles/route.ts b/src/app/api/roles/route.ts new file mode 100644 index 0000000..3d39aa0 --- /dev/null +++ b/src/app/api/roles/route.ts @@ -0,0 +1,27 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "~/db"; +import { rolesTable } from "~/db/schema"; + +export async function GET() { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + const roles = await db + .select({ + id: rolesTable.id, + name: rolesTable.name, + description: rolesTable.description, + }) + .from(rolesTable); + + return NextResponse.json(roles); + } catch (error) { + console.error("Error fetching roles:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/studies/[id]/route.ts b/src/app/api/studies/[id]/route.ts new file mode 100644 index 0000000..f05518e --- /dev/null +++ b/src/app/api/studies/[id]/route.ts @@ -0,0 +1,45 @@ +import { eq } from "drizzle-orm"; +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { db } from "~/db"; +import { studyTable } from "~/db/schema"; +import { hasStudyAccess } from "~/lib/permissions"; + +export async function GET( + request: Request, + context: { params: { id: string } } +) { + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + try { + // Properly await and destructure params + const { id } = await context.params; + const studyId = parseInt(id); + + // Check if user has access to this study + const hasAccess = await hasStudyAccess(userId, studyId); + if (!hasAccess) { + return new NextResponse("Forbidden", { status: 403 }); + } + + // Get study details + const study = await db + .select() + .from(studyTable) + .where(eq(studyTable.id, studyId)) + .limit(1); + + if (!study[0]) { + return new NextResponse("Study not found", { status: 404 }); + } + + return NextResponse.json(study[0]); + } catch (error) { + console.error("Error fetching study:", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} \ No newline at end of file diff --git a/src/app/api/studies/route.ts b/src/app/api/studies/route.ts index be6a97d..f9ed0a8 100644 --- a/src/app/api/studies/route.ts +++ b/src/app/api/studies/route.ts @@ -1,8 +1,8 @@ -import { eq } from "drizzle-orm"; +import { eq, or } from "drizzle-orm"; import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { db } from "~/db"; -import { studyTable, usersTable } from "~/db/schema"; +import { studyTable, usersTable, userRolesTable } from "~/db/schema"; export async function GET() { const { userId } = await auth(); @@ -11,11 +11,26 @@ export async function GET() { return new NextResponse("Unauthorized", { status: 401 }); } + // Get all studies where user is either the owner or has a role const studies = await db - .select() + .select({ + id: studyTable.id, + title: studyTable.title, + description: studyTable.description, + createdAt: studyTable.createdAt, + updatedAt: studyTable.updatedAt, + userId: studyTable.userId, + }) .from(studyTable) - .where(eq(studyTable.userId, userId)); - // TODO: Open up to multiple users + .leftJoin(userRolesTable, eq(userRolesTable.studyId, studyTable.id)) + .where( + or( + eq(studyTable.userId, userId), + eq(userRolesTable.userId, userId) + ) + ) + .groupBy(studyTable.id); + return NextResponse.json(studies); } @@ -30,46 +45,28 @@ export async function POST(request: Request) { } try { - // Debug log - console.log("Creating study for user:", userId); - // Verify user exists first const existingUser = await db .select() .from(usersTable) .where(eq(usersTable.id, userId)); - console.log("Found user:", existingUser[0]); // Debug log - const { title, description } = await request.json(); - - // Debug log - console.log("Study data:", { title, description, userId }); const study = await db .insert(studyTable) .values({ title, description, - userId: userId, // Explicitly use the userId from auth + userId: userId, }) .returning(); - console.log("Created study:", study[0]); // Debug log - return new Response(JSON.stringify(study[0]), { status: 200, headers: { 'Content-Type': 'application/json' } }); } catch (error) { - // Enhanced error logging - console.error("Error details:", { - error, - userId, - errorMessage: error instanceof Error ? error.message : 'Unknown error', - errorName: error instanceof Error ? error.name : 'Unknown error type' - }); - return new Response(JSON.stringify({ error: "Failed to create study", details: error instanceof Error ? error.message : 'Unknown error' diff --git a/src/app/api/webhooks/clerk/route.ts b/src/app/api/webhooks/clerk/route.ts index 3c7cff0..515348d 100644 --- a/src/app/api/webhooks/clerk/route.ts +++ b/src/app/api/webhooks/clerk/route.ts @@ -2,24 +2,19 @@ import { Webhook } from 'svix'; import { headers } from 'next/headers'; import { WebhookEvent } from '@clerk/nextjs/server'; import { db } from '~/db'; -import { usersTable, rolesTable, userRolesTable } from '~/db/schema'; +import { usersTable } from '~/db/schema'; import { eq } from 'drizzle-orm'; export async function POST(req: Request) { - const WEBHOOK_SECRET = process.env.CLERK_WEBHOOK_SECRET; - - if (!WEBHOOK_SECRET) { - throw new Error('Please add CLERK_WEBHOOK_SECRET from Clerk Dashboard to .env'); - } - // Get the headers - const headersList = await headers(); - const svix_id = headersList.get("svix-id"); - const svix_timestamp = headersList.get("svix-timestamp"); - const svix_signature = headersList.get("svix-signature"); + const headerPayload = headers(); + const svix_id = headerPayload.get("svix-id"); + const svix_timestamp = headerPayload.get("svix-timestamp"); + const svix_signature = headerPayload.get("svix-signature"); + // If there are no headers, error out if (!svix_id || !svix_timestamp || !svix_signature) { - return new Response('Error occurred -- no svix headers', { + return new Response('Error occured -- no svix headers', { status: 400 }); } @@ -28,88 +23,43 @@ export async function POST(req: Request) { const payload = await req.json(); const body = JSON.stringify(payload); - // Verify the webhook - const webhook = new Webhook(WEBHOOK_SECRET); - let event: WebhookEvent; + // Create a new Svix instance with your webhook secret + const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET || ''); + let evt: WebhookEvent; + + // Verify the payload with the headers try { - event = webhook.verify(body, { + evt = wh.verify(body, { "svix-id": svix_id, "svix-timestamp": svix_timestamp, "svix-signature": svix_signature, }) as WebhookEvent; } catch (err) { console.error('Error verifying webhook:', err); - return new Response('Error occurred', { + return new Response('Error occured', { status: 400 }); } - const eventType = event.type; + // Handle the webhook + const eventType = evt.type; + console.log(`Webhook received: ${eventType}`); - if (eventType === 'user.created' || eventType === 'user.updated') { - const { id, first_name, last_name, email_addresses, image_url } = event.data; + if (eventType === 'user.created') { + const { id, email_addresses, first_name, last_name } = evt.data; const primaryEmail = email_addresses?.[0]?.email_address; - if (!primaryEmail) { - return new Response('No email found', { status: 400 }); - } + // Create user in our database + await db.insert(usersTable).values({ + id, + email: primaryEmail, + firstName: first_name || null, + lastName: last_name || null, + }).onConflictDoNothing(); - try { - // Combine first and last name - const fullName = [first_name, last_name].filter(Boolean).join(' '); - - // Create/update user with a transaction - await db.transaction(async (tx) => { - // Create/update user - await tx - .insert(usersTable) - .values({ - id, - name: fullName, - email: primaryEmail, - imageUrl: image_url, - }) - .onConflictDoUpdate({ - target: usersTable.id, - set: { - name: fullName, - email: primaryEmail, - imageUrl: image_url, - updatedAt: new Date(), - }, - }); - - // Get or create Observer role - const observerRole = await tx - .select() - .from(rolesTable) - .where(eq(rolesTable.name, 'Observer')) - .limit(1); - - if (observerRole[0]) { - await tx - .insert(userRolesTable) - .values({ - userId: id, - roleId: observerRole[0].id, - }) - .onConflictDoNothing(); - } - }); - - return new Response('User created successfully', { status: 200 }); - } catch (error) { - console.error('Error creating user:', error); - return new Response(JSON.stringify({ error: 'Database error' }), { - status: 500, - headers: { 'Content-Type': 'application/json' } - }); - } + console.log(`Created user in database: ${id}`); } - return new Response(JSON.stringify({ message: 'Webhook processed successfully' }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); + return new Response('', { status: 200 }); } \ No newline at end of file diff --git a/src/app/dashboard/studies/[id]/settings/page.tsx b/src/app/dashboard/studies/[id]/settings/page.tsx new file mode 100644 index 0000000..caa586b --- /dev/null +++ b/src/app/dashboard/studies/[id]/settings/page.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useEffect, useState } from "react"; +import { useParams } from "next/navigation"; +import { Button } from "~/components/ui/button"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter +} from "~/components/ui/card"; +import { InviteUserDialog } from "~/components/invite-user-dialog"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs"; +import { Badge } from "~/components/ui/badge"; +import { format } from "date-fns"; + +interface Invitation { + id: number; + email: string; + accepted: boolean; + expiresAt: string; + createdAt: string; + roleName: string; + inviterName: string; +} + +interface Study { + id: number; + title: string; + description: string | null; + createdAt: string; +} + +export default function StudySettings() { + const params = useParams(); + const studyId = parseInt(params.id as string); + const [study, setStudy] = useState(null); + const [invitations, setInvitations] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchStudyData(); + fetchInvitations(); + }, [studyId]); + + const fetchStudyData = async () => { + try { + const response = await fetch(`/api/studies/${studyId}`); + if (response.ok) { + const data = await response.json(); + setStudy(data); + } + } catch (error) { + console.error('Error fetching study:', error); + } + }; + + const fetchInvitations = async () => { + try { + const response = await fetch(`/api/invitations?studyId=${studyId}`); + if (response.ok) { + const data = await response.json(); + setInvitations(data); + } + } catch (error) { + console.error('Error fetching invitations:', error); + } finally { + setLoading(false); + } + }; + + const handleInviteSent = () => { + fetchInvitations(); + }; + + const handleDeleteInvitation = async (invitationId: number) => { + try { + const response = await fetch(`/api/invitations/${invitationId}`, { + method: 'DELETE', + }); + + if (response.ok) { + // Update the local state to remove the deleted invitation + setInvitations(invitations.filter(inv => inv.id !== invitationId)); + } else { + console.error('Error deleting invitation:', response.statusText); + } + } catch (error) { + console.error('Error deleting invitation:', error); + } + }; + + if (loading) { + return
Loading...
; + } + + if (!study) { + return
Study not found
; + } + + return ( +
+
+
+

{study.title}

+

Study Settings

+
+
+ + + + Invites + Settings + + + + + +
+
+ Study Invitations + + Manage invitations to collaborate on this study + +
+ +
+
+ +
+ {invitations.length > 0 ? ( + invitations.map((invitation) => ( +
+
+
{invitation.email}
+
+ Role: {invitation.roleName} +
+
+ Invited by: {invitation.inviterName} on{" "} + {format(new Date(invitation.createdAt), "PPP")} +
+
+
+ + {invitation.accepted ? "Accepted" : "Pending"} + + {!invitation.accepted && ( + + )} +
+
+ )) + ) : ( +
+ No invitations sent yet. Use the "Invite User" button to get started. +
+ )} +
+
+
+
+ + + + + Study Settings + + Configure general settings for your study + + + + {/* TODO: Add study settings form */} +
+ Study settings coming soon... +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/dashboard/studies/page.tsx b/src/app/dashboard/studies/page.tsx index e3377b4..084d296 100644 --- a/src/app/dashboard/studies/page.tsx +++ b/src/app/dashboard/studies/page.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Button } from "~/components/ui/button"; -import { PlusIcon, Trash2Icon } from "lucide-react"; +import { PlusIcon, Trash2Icon, Settings2Icon } from "lucide-react"; import { Card, CardHeader, @@ -14,6 +14,7 @@ import { import { Input } from "~/components/ui/input"; import { Textarea } from "~/components/ui/textarea"; import { Label } from "~/components/ui/label"; +import Link from "next/link"; interface Study { id: number; @@ -149,9 +150,20 @@ export default function Studies() { )} - +
+ + +
diff --git a/src/app/invite/accept/[token]/invitation-accept-content.tsx b/src/app/invite/accept/[token]/invitation-accept-content.tsx new file mode 100644 index 0000000..0e0898d --- /dev/null +++ b/src/app/invite/accept/[token]/invitation-accept-content.tsx @@ -0,0 +1,142 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; +import { Button } from "~/components/ui/button"; +import { useUser } from "@clerk/nextjs"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { Loader2 } from "lucide-react"; +import { Logo } from "~/components/logo"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "~/components/ui/alert-dialog"; + +interface InvitationAcceptContentProps { + token: string; +} + +export function InvitationAcceptContent({ token }: InvitationAcceptContentProps) { + const { isLoaded, isSignedIn } = useUser(); + const router = useRouter(); + const [isAccepting, setIsAccepting] = useState(false); + const [error, setError] = useState(null); + + const handleAcceptInvitation = async () => { + setIsAccepting(true); + setError(null); + + try { + const response = await fetch(`/api/invitations/accept/${token}`, { + method: "POST", + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to accept invitation"); + } + + router.push("/dashboard"); + } catch (error) { + setError(error instanceof Error ? error.message : "Failed to accept invitation"); + setIsAccepting(false); + } + }; + + if (!isLoaded) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+
+ +
+

+ A platform for managing human-robot interaction studies +

+
+ + + + Research Study Invitation + + You've been invited to collaborate on a research study. {!isSignedIn && " Please sign in or create an account to continue."} + + + + {error && ( +
+ {error} +
+ )} + + {isSignedIn ? ( + + + + + + + Accept Research Study Invitation + + Are you sure you want to accept this invitation? You will be added as a collaborator to the research study. + + + + Cancel + + {isAccepting ? ( + <> + + Accepting... + + ) : ( + "Accept" + )} + + + + + ) : ( +
+ + +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/invite/accept/[token]/page.tsx b/src/app/invite/accept/[token]/page.tsx new file mode 100644 index 0000000..7d7ca31 --- /dev/null +++ b/src/app/invite/accept/[token]/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; +import { Loader2 } from "lucide-react"; +import { InvitationAcceptContent } from "./invitation-accept-content"; + +interface InvitationAcceptPageProps { + params: { token: string }; +} + +export default async function InvitationAcceptPage({ params }: InvitationAcceptPageProps) { + const token = await Promise.resolve(params.token); + + return ( + + + + } + > + + + ); +} \ No newline at end of file diff --git a/src/app/sign-in/[[...sign-in]]/page.tsx b/src/app/sign-in/[[...sign-in]]/page.tsx new file mode 100644 index 0000000..9bedaf0 --- /dev/null +++ b/src/app/sign-in/[[...sign-in]]/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from "react"; +import { SignIn } from "@clerk/nextjs"; + +export default function SignInPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/app/sign-up/[[...sign-up]]/page.tsx b/src/app/sign-up/[[...sign-up]]/page.tsx new file mode 100644 index 0000000..9ed044a --- /dev/null +++ b/src/app/sign-up/[[...sign-up]]/page.tsx @@ -0,0 +1,12 @@ +'use client'; + +import React from "react"; +import { SignUp } from "@clerk/nextjs"; + +export default function SignUpPage() { + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/invite-user-dialog.tsx b/src/components/invite-user-dialog.tsx new file mode 100644 index 0000000..561e406 --- /dev/null +++ b/src/components/invite-user-dialog.tsx @@ -0,0 +1,157 @@ +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "~/components/ui/dialog"; +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Label } from "~/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; +import { ROLES } from "~/lib/roles"; +import { UserPlusIcon } from "lucide-react"; + +interface Role { + id: number; + name: string; +} + +interface InviteUserDialogProps { + studyId: number; + onInviteSent?: () => void; +} + +export function InviteUserDialog({ studyId, onInviteSent }: InviteUserDialogProps) { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [roleId, setRoleId] = useState(""); + const [loading, setLoading] = useState(false); + const [roles, setRoles] = useState([]); + const router = useRouter(); + + useEffect(() => { + // Fetch available roles when dialog opens + if (open) { + fetchRoles(); + } + }, [open]); + + const fetchRoles = async () => { + try { + const response = await fetch("/api/roles"); + if (response.ok) { + const data = await response.json(); + setRoles(data.filter((role: Role) => + role.name !== ROLES.ADMIN && role.name !== ROLES.PRINCIPAL_INVESTIGATOR + )); + } + } catch (error) { + console.error("Error fetching roles:", error); + } + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + + try { + const response = await fetch("/api/invitations", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + email, + studyId, + roleId: parseInt(roleId), + }), + }); + + if (!response.ok) { + throw new Error("Failed to send invitation"); + } + + setOpen(false); + setEmail(""); + setRoleId(""); + onInviteSent?.(); + router.refresh(); + } catch (error) { + console.error("Error sending invitation:", error); + } finally { + setLoading(false); + } + }; + + return ( + + + + + + + Invite User + + Send an invitation to join your study. The user will receive an email with instructions. + + +
+
+
+ + setEmail(e.target.value)} + placeholder="researcher@university.edu" + required + /> +
+
+ + +
+
+ + + +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..9c98358 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..6c72a38 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -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 & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx new file mode 100644 index 0000000..5e2b7ac --- /dev/null +++ b/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 0000000..1612f48 --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,121 @@ +"use client" + +import * as React from "react" +import * as DialogPrimitive from "@radix-ui/react-dialog" +import { cn } from "~/lib/utils" +import { Cross2Icon } from "@radix-ui/react-icons" + +const Dialog = DialogPrimitive.Root + +const DialogTrigger = DialogPrimitive.Trigger + +const DialogPortal = DialogPrimitive.Portal + +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = DialogPrimitive.Description.displayName + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/src/components/ui/tabs.tsx b/src/components/ui/tabs.tsx new file mode 100644 index 0000000..8a852e3 --- /dev/null +++ b/src/components/ui/tabs.tsx @@ -0,0 +1,55 @@ +"use client" + +import * as React from "react" +import * as TabsPrimitive from "@radix-ui/react-tabs" + +import { cn } from "~/lib/utils" + +const Tabs = TabsPrimitive.Root + +const TabsList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsList.displayName = TabsPrimitive.List.displayName + +const TabsTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsTrigger.displayName = TabsPrimitive.Trigger.displayName + +const TabsContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +TabsContent.displayName = TabsPrimitive.Content.displayName + +export { Tabs, TabsList, TabsTrigger, TabsContent } diff --git a/src/db/schema.ts b/src/db/schema.ts index 1ffcf4d..3609e63 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,5 +1,5 @@ import { sql, relations } from 'drizzle-orm'; -import { integer, pgTable, serial, text, timestamp, varchar, primaryKey } from "drizzle-orm/pg-core"; +import { integer, pgTable, serial, text, timestamp, varchar, primaryKey, boolean, uniqueIndex } from "drizzle-orm/pg-core"; export const usersTable = pgTable("users", { id: varchar("id", { length: 256 }).primaryKey(), @@ -63,10 +63,38 @@ export const userRolesTable = pgTable("user_roles", { roleId: integer("role_id") .references(() => rolesTable.id) .notNull(), + studyId: integer("study_id") + .references(() => studyTable.id) + .notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").$onUpdate(() => new Date()), }, (table) => ({ - pk: primaryKey({ columns: [table.userId, table.roleId] }), + pk: primaryKey({ columns: [table.userId, table.roleId, table.studyId] }), })); +export const invitationsTable = pgTable("invitations", { + id: serial("id").primaryKey(), + email: varchar("email", { length: 256 }).notNull(), + studyId: integer("study_id") + .references(() => studyTable.id) + .notNull(), + roleId: integer("role_id") + .references(() => rolesTable.id) + .notNull(), + token: varchar("token", { length: 100 }).notNull().unique(), + invitedById: varchar("invited_by_id", { length: 256 }) + .references(() => usersTable.id) + .notNull(), + accepted: boolean("accepted").default(false).notNull(), + expiresAt: timestamp("expires_at").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").$onUpdate(() => new Date()), +}, (table) => { + return { + tokenIdx: uniqueIndex('invitations_token_idx').on(table.token), + }; +}); + export const usersRelations = relations(usersTable, ({ many }) => ({ studies: many(studyTable), userRoles: many(userRolesTable), @@ -78,6 +106,8 @@ export const studyRelations = relations(studyTable, ({ one, many }) => ({ references: [usersTable.id], }), participants: many(participantsTable), + invitations: many(invitationsTable), + userRoles: many(userRolesTable), })); export const participantRelations = relations(participantsTable, ({ one }) => ({ @@ -116,4 +146,23 @@ export const userRolesRelations = relations(userRolesTable, ({ one }) => ({ fields: [userRolesTable.roleId], references: [rolesTable.id], }), + study: one(studyTable, { + fields: [userRolesTable.studyId], + references: [studyTable.id], + }), +})); + +export const invitationsRelations = relations(invitationsTable, ({ one }) => ({ + study: one(studyTable, { + fields: [invitationsTable.studyId], + references: [studyTable.id], + }), + role: one(rolesTable, { + fields: [invitationsTable.roleId], + references: [rolesTable.id], + }), + invitedBy: one(usersTable, { + fields: [invitationsTable.invitedById], + references: [usersTable.id], + }), })); \ No newline at end of file diff --git a/src/db/seed.ts b/src/db/seed.ts index b515cfe..bf82006 100644 --- a/src/db/seed.ts +++ b/src/db/seed.ts @@ -1,8 +1,9 @@ import { config } from "dotenv"; +import { eq } from "drizzle-orm"; import { db } from "./index"; import { PERMISSIONS } from "~/lib/permissions"; import { ROLES, ROLE_PERMISSIONS } from "~/lib/roles"; -import { permissionsTable, rolesTable, rolePermissionsTable } from "./schema"; +import { permissionsTable, rolesTable, rolePermissionsTable, userRolesTable, usersTable, studyTable } from "./schema"; // Load environment variables from .env.local config({ path: ".env.local" }); @@ -56,6 +57,31 @@ async function seed() { } } + // Get the first user and assign them as a Principal Investigator for their studies + console.log("Setting up initial user roles..."); + const users = await db.select().from(usersTable); + if (users.length > 0) { + const piRole = roles.find(r => r.name === ROLES.PRINCIPAL_INVESTIGATOR); + if (piRole) { + // Get all studies owned by the first user + const userStudies = await db + .select() + .from(studyTable) + .where(eq(studyTable.userId, users[0].id)); + + // Assign PI role for each study + for (const study of userStudies) { + await db.insert(userRolesTable) + .values({ + userId: users[0].id, + roleId: piRole.id, + studyId: study.id, + }) + .onConflictDoNothing(); + } + } + } + console.log("✅ Seeding complete!"); } diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..734df13 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,76 @@ +import nodemailer from 'nodemailer'; + +// Create reusable transporter object using SMTP transport +const transporter = nodemailer.createTransport({ + service: 'iCloud', + secure: false, + auth: { + user: 'soconnor0919@icloud.com', + pass: 'uhlb-virv-qqpk-puwc', + }, +}); + +// Verify connection configuration +transporter.verify(function(error, success) { + if (error) { + console.log('SMTP Verification Error:', error); + } +}); + +interface SendInvitationEmailParams { + to: string; + inviterName: string; + studyTitle: string; + role: string; + token: string; +} + +export async function sendInvitationEmail({ + to, + inviterName, + studyTitle, + role, + token, +}: SendInvitationEmailParams) { + const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/accept/${token}`; + + const html = ` +

You've been invited to join HRIStudio

+

${inviterName} has invited you to join their study "${studyTitle}" as a ${role}.

+

HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments.

+

Click the button below to accept the invitation and join the study:

+ Accept Invitation +

Or copy and paste this URL into your browser:

+

${inviteUrl}

+

This invitation will expire in 7 days.

+ `; + + const text = ` +You've been invited to join HRIStudio + +${inviterName} has invited you to join their study "${studyTitle}" as a ${role}. + +HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments. + +To accept the invitation, visit this URL: +${inviteUrl} + +This invitation will expire in 7 days. + `; + + await transporter.sendMail({ + from: `"HRIStudio" <${process.env.SMTP_FROM_ADDRESS}>`, + to, + subject: `Invitation to join "${studyTitle}" on HRIStudio`, + text, + html, + }); +} \ No newline at end of file diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index 10227e1..72668de 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -29,8 +29,15 @@ export const PERMISSIONS = { export type PermissionCode = keyof typeof PERMISSIONS; -export async function getUserPermissions(userId: string) { - // Get all permissions for the user through their roles +export async function getUserPermissions(userId: string, studyId?: number) { + // Build the base query conditions + const conditions = [eq(userRolesTable.userId, userId)]; + + // If studyId is provided, add it to conditions + if (studyId) { + conditions.push(eq(userRolesTable.studyId, studyId)); + } + const userPermissions = await db .select({ permissionCode: permissionsTable.code, @@ -44,15 +51,24 @@ export async function getUserPermissions(userId: string) { permissionsTable, eq(rolePermissionsTable.permissionId, permissionsTable.id) ) - .where(eq(userRolesTable.userId, userId)); + .where(and(...conditions)); return userPermissions.map(p => p.permissionCode); } -export async function hasPermission(userId: string, permissionCode: string) { +export async function hasPermission(userId: string, permissionCode: string, studyId?: number) { + console.log("Checking permission:", { + userId, + permissionCode, + studyId, + permissionConstant: PERMISSIONS.MANAGE_ROLES + }); + const result = await db .select({ id: permissionsTable.id, + code: permissionsTable.code, + roleId: userRolesTable.roleId, }) .from(userRolesTable) .innerJoin( @@ -66,10 +82,55 @@ export async function hasPermission(userId: string, permissionCode: string) { .where( and( eq(userRolesTable.userId, userId), - eq(permissionsTable.code, permissionCode) + eq(permissionsTable.code, permissionCode), + studyId ? eq(userRolesTable.studyId, studyId) : undefined + ) + ); + + console.log("Permission check details:", { + query: "Executed", + foundPermissions: result.map(r => ({ roleId: r.roleId, code: r.code })) + }); + + return result.length > 0; +} + +// Helper function to check if user has any role in a study +export async function hasStudyAccess(userId: string, studyId: number) { + const result = await db + .select() + .from(userRolesTable) + .where( + and( + eq(userRolesTable.userId, userId), + eq(userRolesTable.studyId, studyId) ) ) .limit(1); return result.length > 0; } + +// Helper function to get all studies a user has access to +export async function getUserStudies(userId: string) { + return db + .selectDistinct({ studyId: userRolesTable.studyId }) + .from(userRolesTable) + .where(eq(userRolesTable.userId, userId)); +} + +// Helper function to get all roles a user has in a study +export async function getUserStudyRoles(userId: string, studyId: number) { + return db + .select({ + roleId: userRolesTable.roleId, + createdAt: userRolesTable.createdAt, + }) + .from(userRolesTable) + .where( + and( + eq(userRolesTable.userId, userId), + eq(userRolesTable.studyId, studyId) + ) + ); +} diff --git a/src/middleware.ts b/src/middleware.ts index 841ec11..0ebfb74 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,16 +1,34 @@ import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; +import { NextResponse } from 'next/server' -const isProtectedRoute = createRouteMatcher(['/dashboard(.*)']) +const isPublicRoute = createRouteMatcher([ + '/', + '/sign-in(.*)', + '/sign-up(.*)', + '/invite/accept/(.*)' +]) + +const isProtectedRoute = createRouteMatcher([ + '/dashboard(.*)', + '/api/invitations/accept/(.*)' +]) export default clerkMiddleware(async (auth, req) => { - if (isProtectedRoute(req)) await auth.protect() + if (isPublicRoute(req)) { + return NextResponse.next() + } + + if (isProtectedRoute(req)) { + await auth.protect() + return NextResponse.next() + } + + return NextResponse.next() }) export const config = { matcher: [ - // Skip Next.js internals and all static files, unless found in search params '/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)', - // Always run for API routes '/(api|trpc)(.*)', ], }; \ No newline at end of file diff --git a/src/scripts/test-email.ts b/src/scripts/test-email.ts new file mode 100644 index 0000000..58d1f6f --- /dev/null +++ b/src/scripts/test-email.ts @@ -0,0 +1,32 @@ +import { config } from 'dotenv'; +import { sendInvitationEmail } from '~/lib/email'; + +// Load environment variables from .env.local +config({ path: '.env.local' }); + +async function testEmail() { + try { + // Create a test invitation + const invitationData = { + to: 'soconnor0919@gmail.com', + inviterName: 'Sean O\'Connor', + studyTitle: 'Robot Navigation Study', + role: 'Researcher', + token: 'test-' + Math.random().toString(36).substring(2, 15), + }; + + console.log('Sending invitation with the following details:'); + console.log('To:', invitationData.to); + console.log('Role:', invitationData.role); + console.log('Study:', invitationData.studyTitle); + console.log('Token:', invitationData.token); + console.log('Invite URL:', `${process.env.NEXT_PUBLIC_APP_URL}/invite/accept/${invitationData.token}`); + + await sendInvitationEmail(invitationData); + console.log('✅ Invitation email sent successfully!'); + } catch (error) { + console.error('❌ Error sending invitation email:', error); + } +} + +testEmail();