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.
This commit is contained in:
2024-12-03 23:02:23 -05:00
parent 3a955a0568
commit 3ec8b2fe46
28 changed files with 1775 additions and 121 deletions

View File

@@ -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"

View File

@@ -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",

119
pnpm-lock.yaml generated
View File

@@ -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: {}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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'

View File

@@ -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 });
}

View File

@@ -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<Study | null>(null);
const [invitations, setInvitations] = useState<Invitation[]>([]);
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 <div>Loading...</div>;
}
if (!study) {
return <div>Study not found</div>;
}
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">{study.title}</h1>
<p className="text-muted-foreground mt-1">Study Settings</p>
</div>
</div>
<Tabs defaultValue="invites" className="space-y-4">
<TabsList>
<TabsTrigger value="invites">Invites</TabsTrigger>
<TabsTrigger value="settings">Settings</TabsTrigger>
</TabsList>
<TabsContent value="invites">
<Card>
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle>Study Invitations</CardTitle>
<CardDescription>
Manage invitations to collaborate on this study
</CardDescription>
</div>
<InviteUserDialog studyId={studyId} onInviteSent={handleInviteSent} />
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{invitations.length > 0 ? (
invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div className="space-y-1">
<div className="font-medium">{invitation.email}</div>
<div className="text-sm text-muted-foreground">
Role: {invitation.roleName}
</div>
<div className="text-sm text-muted-foreground">
Invited by: {invitation.inviterName} on{" "}
{format(new Date(invitation.createdAt), "PPP")}
</div>
</div>
<div className="flex items-center gap-4">
<Badge
variant={invitation.accepted ? "success" : "secondary"}
>
{invitation.accepted ? "Accepted" : "Pending"}
</Badge>
{!invitation.accepted && (
<Button
variant="ghost"
size="sm"
className="text-destructive"
onClick={() => handleDeleteInvitation(invitation.id)}
>
Cancel
</Button>
)}
</div>
</div>
))
) : (
<div className="text-center py-8 text-muted-foreground">
No invitations sent yet. Use the "Invite User" button to get started.
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings">
<Card>
<CardHeader>
<CardTitle>Study Settings</CardTitle>
<CardDescription>
Configure general settings for your study
</CardDescription>
</CardHeader>
<CardContent>
{/* TODO: Add study settings form */}
<div className="text-center py-8 text-muted-foreground">
Study settings coming soon...
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -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() {
</CardDescription>
)}
</div>
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => deleteStudy(study.id)}>
<Trash2Icon className="w-4 h-4" />
</Button>
<div className="flex gap-2">
<Button
variant="ghost"
size="icon"
asChild
>
<Link href={`/dashboard/studies/${study.id}/settings`}>
<Settings2Icon className="w-4 h-4" />
</Link>
</Button>
<Button variant="ghost" size="icon" className="text-destructive" onClick={() => deleteStudy(study.id)}>
<Trash2Icon className="w-4 h-4" />
</Button>
</div>
</div>
</CardHeader>
<CardFooter className="text-sm text-muted-foreground">

View File

@@ -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<string | null>(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 (
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50/50">
<div className="w-full max-w-md px-4 py-8">
<div className="flex flex-col items-center mb-8">
<div className="mb-6">
<Logo className="h-10" />
</div>
<p className="text-gray-500 text-center">
A platform for managing human-robot interaction studies
</p>
</div>
<Card className="shadow-lg">
<CardHeader>
<CardTitle>Research Study Invitation</CardTitle>
<CardDescription>
You've been invited to collaborate on a research study. {!isSignedIn && " Please sign in or create an account to continue."}
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 p-4 text-sm text-red-800 bg-red-100 rounded-lg">
{error}
</div>
)}
{isSignedIn ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button className="w-full" disabled={isAccepting}>
Accept Invitation
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Accept Research Study Invitation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to accept this invitation? You will be added as a collaborator to the research study.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleAcceptInvitation}
disabled={isAccepting}
>
{isAccepting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Accepting...
</>
) : (
"Accept"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<div className="flex flex-col gap-3">
<Button
variant="default"
className="w-full"
onClick={() => router.push(`/sign-in?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
>
Sign In
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => router.push(`/sign-up?redirect_url=${encodeURIComponent(`/invite/accept/${token}`)}`)}
>
Create Account
</Button>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -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 (
<Suspense
fallback={
<div className="flex items-center justify-center min-h-screen">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
}
>
<InvitationAcceptContent token={token} />
</Suspense>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import React from "react";
import { SignIn } from "@clerk/nextjs";
export default function SignInPage() {
return (
<div className="container flex items-center justify-center min-h-screen py-10">
<SignIn />
</div>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import React from "react";
import { SignUp } from "@clerk/nextjs";
export default function SignUpPage() {
return (
<div className="container flex items-center justify-center min-h-screen py-10">
<SignUp />
</div>
);
}

View File

@@ -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<Role[]>([]);
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 (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<UserPlusIcon className="w-4 h-4 mr-2" />
Invite User
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Send an invitation to join your study. The user will receive an email with instructions.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="email">Email Address</Label>
<Input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="researcher@university.edu"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select
value={roleId}
onValueChange={setRoleId}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
type="submit"
disabled={loading}
>
{loading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View 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,
}

View 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 }

View 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 }

View File

@@ -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<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.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}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -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<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -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],
}),
}));

View File

@@ -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!");
}

76
src/lib/email.ts Normal file
View File

@@ -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 = `
<h2>You've been invited to join HRIStudio</h2>
<p>${inviterName} has invited you to join their study "${studyTitle}" as a ${role}.</p>
<p>HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments.</p>
<p>Click the button below to accept the invitation and join the study:</p>
<a href="${inviteUrl}" style="
display: inline-block;
background-color: #0070f3;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
margin: 16px 0;
">Accept Invitation</a>
<p>Or copy and paste this URL into your browser:</p>
<p>${inviteUrl}</p>
<p>This invitation will expire in 7 days.</p>
`;
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,
});
}

View File

@@ -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)
)
);
}

View File

@@ -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)(.*)',
],
};

32
src/scripts/test-email.ts Normal file
View File

@@ -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();