chore(deps): Update project dependencies and refactor authentication flow

- Upgrade Next.js to version 15.1.7
- Update Drizzle ORM and related dependencies
- Add Nodemailer and related type definitions
- Refactor authentication routes and components
- Modify user schema to include first and last name
- Update authentication configuration and session handling
- Remove deprecated login and register pages
- Restructure authentication-related components and routes
This commit is contained in:
2025-02-11 23:55:27 -05:00
parent e6962aef79
commit 6e3f2e1601
65 changed files with 6171 additions and 1273 deletions

View File

@@ -1,149 +0,0 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useToast } from "~/components/ui/use-toast";
import { api } from "~/trpc/react";
const formSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
name: z.string().min(1).max(256).optional(),
});
type FormValues = z.infer<typeof formSchema>;
export function SignUpForm() {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const createUser = api.user.create.useMutation();
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
email: "",
password: "",
name: "",
},
});
async function onSubmit(data: FormValues) {
setIsLoading(true);
try {
await createUser.mutateAsync(data);
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
return;
}
router.refresh();
router.push("/");
} catch (error) {
if (error instanceof Error) {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
} else {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
});
}
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="name@example.com"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input
placeholder="John Doe"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
</form>
</Form>
);
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { db } from "~/server/db";
import { users } from "~/server/db/schema";
import { randomUUID } from "crypto";
const registerSchema = z.object({
firstName: z.string().min(1, "First name is required"),
@@ -45,13 +46,14 @@ export async function POST(req: Request) {
const hashedPassword = await hash(password, 10);
await db.insert(users).values({
id: randomUUID(),
firstName,
lastName,
email,
password: hashedPassword,
});
return NextResponse.redirect(new URL("/login", req.url));
return NextResponse.json({ success: true });
} catch (error) {
console.error(error);
return NextResponse.json(

View File

@@ -1,67 +1,77 @@
import { type Metadata } from "next";
import type { Metadata } from "next";
import Link from "next/link";
import { SignInForm } from "~/app/_components/auth/sign-in-form";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "~/components/ui/card";
import { SignInForm } from "~/components/auth/sign-in-form";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Sign In",
title: "Sign In | HRIStudio",
description: "Sign in to your account",
};
export default function SignInPage() {
export default async function SignInPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
const params = await searchParams;
const error = params?.error ? String(params.error) : null;
const showError = error === "CredentialsSignin";
return (
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Welcome back
</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</div>
<SignInForm error={showError} />
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
<p className="mt-6 text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<Link
href={`/auth/signup${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
className="underline underline-offset-4 hover:text-primary"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
HRI Studio
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;HRI Studio has revolutionized how we conduct human-robot interaction studies.&rdquo;
</p>
<footer className="text-sm">Sofia Dewar</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Welcome back
</h1>
<p className="text-sm text-muted-foreground">
Enter your email to sign in to your account
</p>
</div>
<SignInForm />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/auth/signup"
className={cn(
buttonVariants({ variant: "ghost" }),
"hover:bg-transparent hover:underline",
"px-0"
)}
>
Don&apos;t have an account? Sign Up
</Link>
</p>
</div>
Sign up
</Link>
</p>
</div>
</div>
);

View File

@@ -1,67 +1,77 @@
import { type Metadata } from "next";
import type { Metadata } from "next";
import Link from "next/link";
import { SignUpForm } from "~/app/_components/auth/sign-up-form";
import { buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "~/components/ui/card";
import { SignUpForm } from "~/components/auth/sign-up-form";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Sign Up",
title: "Sign Up | HRIStudio",
description: "Create a new account",
};
export default function SignUpPage() {
export default async function SignUpPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
const params = await searchParams;
const error = params?.error ? String(params.error) : null;
const showError = error === "CredentialsSignin";
return (
<div className="container relative h-screen flex-col items-center justify-center grid lg:max-w-none lg:grid-cols-2 lg:px-0">
<div className="relative hidden h-full flex-col bg-muted p-10 text-white lg:flex dark:border-r">
<div className="absolute inset-0 bg-zinc-900" />
<div className="relative z-20 flex items-center text-lg font-medium">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-6 w-6"
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Create an account
</CardTitle>
<CardDescription className="text-base">
Get started with HRIStudio
</CardDescription>
</div>
<SignUpForm error={showError} />
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
<p className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href={`/auth/signin${params?.callbackUrl ? `?callbackUrl=${params.callbackUrl}` : ''}`}
className="underline underline-offset-4 hover:text-primary"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
HRI Studio
</div>
<div className="relative z-20 mt-auto">
<blockquote className="space-y-2">
<p className="text-lg">
&ldquo;HRI Studio has revolutionized how we conduct human-robot interaction studies.&rdquo;
</p>
<footer className="text-sm">Sofia Dewar</footer>
</blockquote>
</div>
</div>
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<SignUpForm />
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/auth/signin"
className={cn(
buttonVariants({ variant: "ghost" }),
"hover:bg-transparent hover:underline",
"px-0"
)}
>
Already have an account? Sign In
</Link>
</p>
</div>
Sign in
</Link>
</p>
</div>
</div>
);

View File

@@ -3,6 +3,7 @@
import { useEffect } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "next-auth/react"
import { api } from "~/trpc/react"
import { AppSidebar } from "~/components/navigation/app-sidebar"
import { Header } from "~/components/navigation/header"
@@ -18,14 +19,29 @@ export default function DashboardLayout({
const { data: session, status } = useSession()
const router = useRouter()
// Get user's studies
const { data: studies, isLoading: isLoadingStudies } = api.study.getMyStudies.useQuery(
undefined,
{
enabled: status === "authenticated",
}
);
useEffect(() => {
if (status === "unauthenticated") {
router.replace("/login")
router.replace("/auth/signin")
}
}, [status, router])
useEffect(() => {
// Only redirect if we've loaded studies and user has none
if (!isLoadingStudies && studies && studies.length === 0) {
router.replace("/onboarding")
}
}, [studies, isLoadingStudies, router])
// Show nothing while loading
if (status === "loading") {
if (status === "loading" || isLoadingStudies) {
return null
}
@@ -34,6 +50,11 @@ export default function DashboardLayout({
return null
}
// Show nothing if no studies (will redirect to onboarding)
if (studies && studies.length === 0) {
return null
}
return (
<SidebarProvider>
<StudyProvider>

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { api } from "~/trpc/react";
interface DeleteStudyButtonProps {
id: number;
}
export default function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
onSuccess: () => {
router.push("/studies");
router.refresh();
},
});
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the study
and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteStudy({ id })}
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,65 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { use } from "react";
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const resolvedParams = use(params);
const id = Number(resolvedParams.id);
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery(
{ id }
);
const { mutate: updateStudy, isPending: isUpdating } = api.study.update.useMutation({
onSuccess: () => {
router.push(`/dashboard/studies/${id}`);
router.refresh();
},
});
function onSubmit(data: StudyFormValues) {
updateStudy({ id, ...data });
}
if (isLoadingStudy) {
return <div>Loading...</div>;
}
if (!study) {
return <div>Study not found</div>;
}
return (
<>
<PageHeader
title="Edit Study"
description="Update study details"
/>
<PageContent className="max-w-2xl">
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>
Update the information for your study.
</CardDescription>
</CardHeader>
<CardContent>
<StudyForm
defaultValues={{ title: study.title, description: study.description ?? "" }}
onSubmit={onSubmit}
isSubmitting={isUpdating}
submitLabel="Save Changes"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import { Pencil as PencilIcon } from "lucide-react";
import { use } from "react";
import { StudyOverview } from "~/components/studies/study-overview";
import { StudyParticipants } from "~/components/studies/study-participants";
import { StudyMembers } from "~/components/studies/study-members";
import { StudyMetadata } from "~/components/studies/study-metadata";
import { StudyActivity } from "~/components/studies/study-activity";
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const searchParams = useSearchParams();
const resolvedParams = use(params);
const id = Number(resolvedParams.id);
const activeTab = searchParams.get("tab") ?? "overview";
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
if (isLoadingStudy) {
return <div>Loading...</div>;
}
if (!study) {
return <div>Study not found</div>;
}
const canEdit = study.role === "admin";
return (
<>
<PageHeader
title={study.title}
description={study.description ?? "No description provided"}
>
{canEdit && (
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/studies/${id}/edit`)}
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit Study
</Button>
)}
</PageHeader>
<PageContent>
<Tabs defaultValue={activeTab} className="space-y-4">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="participants">Participants</TabsTrigger>
<TabsTrigger value="members">Members</TabsTrigger>
<TabsTrigger value="metadata">Metadata</TabsTrigger>
<TabsTrigger value="activity">Activity</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<StudyOverview study={study} />
</TabsContent>
<TabsContent value="participants" className="space-y-4">
<StudyParticipants studyId={id} role={study.role} />
</TabsContent>
<TabsContent value="members" className="space-y-4">
<StudyMembers studyId={id} role={study.role} />
</TabsContent>
<TabsContent value="metadata" className="space-y-4">
<StudyMetadata studyId={id} role={study.role} />
</TabsContent>
<TabsContent value="activity" className="space-y-4">
<StudyActivity studyId={id} role={study.role} />
</TabsContent>
</Tabs>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
import { use } from "react";
import { useToast } from "~/hooks/use-toast";
import { ROLES } from "~/lib/permissions/constants";
export default function EditParticipantPage({
params,
}: {
params: Promise<{ id: string; participantId: string }>;
}) {
const router = useRouter();
const { toast } = useToast();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const participantId = Number(resolvedParams.participantId);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
const { mutate: updateParticipant, isPending: isUpdating } = api.participant.update.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Participant updated successfully",
});
router.push(`/dashboard/studies/${studyId}/participants/${participantId}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
function onSubmit(data: ParticipantFormValues) {
updateParticipant({
id: participantId,
...data,
});
}
if (isLoading) {
return <div>Loading...</div>;
}
if (!study || !participant) {
return <div>Not found</div>;
}
// Check if user has permission to edit participants
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
if (!canManageParticipants) {
return <div>You do not have permission to edit participants in this study.</div>;
}
return (
<>
<PageHeader
title="Edit Participant"
description={`Update participant details for ${study.title}`}
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Participant Details</CardTitle>
<CardDescription>
Update the participant's information. Fields marked with * are required.
</CardDescription>
</CardHeader>
<CardContent>
<ParticipantForm
defaultValues={{
identifier: participant.identifier ?? "",
email: participant.email ?? "",
firstName: participant.firstName ?? "",
lastName: participant.lastName ?? "",
notes: participant.notes ?? "",
status: participant.status,
}}
onSubmit={onSubmit}
isSubmitting={isUpdating}
submitLabel="Save Changes"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,182 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Pencil as PencilIcon, Trash as TrashIcon } from "lucide-react";
import { use } from "react";
import { Badge } from "~/components/ui/badge";
import { useToast } from "~/hooks/use-toast";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { ROLES } from "~/lib/permissions/constants";
export default function ParticipantDetailsPage({
params,
}: {
params: Promise<{ id: string; participantId: string }>;
}) {
const router = useRouter();
const { toast } = useToast();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const participantId = Number(resolvedParams.participantId);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: participant, isLoading } = api.participant.getById.useQuery({ id: participantId });
const { mutate: deleteParticipant, isPending: isDeleting } = api.participant.delete.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Participant deleted successfully",
});
router.push(`/dashboard/studies/${studyId}/participants`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
if (isLoading) {
return <div>Loading...</div>;
}
if (!study || !participant) {
return <div>Not found</div>;
}
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
return (
<>
<PageHeader
title="Participant Details"
description={`View participant details for ${study.title}`}
>
{canManageParticipants && (
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
router.push(`/dashboard/studies/${studyId}/participants/${participantId}/edit`)
}
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="destructive" size="sm" disabled={isDeleting}>
<TrashIcon className="h-4 w-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the participant and all
associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteParticipant({ id: participantId })}
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</PageHeader>
<PageContent>
<div className="grid gap-6">
<Card>
<CardHeader>
<CardTitle>Basic Information</CardTitle>
{!canViewIdentifiableInfo && (
<CardDescription className="text-yellow-600">
Some information is redacted based on your role.
</CardDescription>
)}
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">Identifier</dt>
<dd className="mt-1 text-sm">
{canViewIdentifiableInfo ? participant.identifier || "—" : "REDACTED"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Status</dt>
<dd className="mt-1">
<Badge variant="secondary">
{participant.status}
</Badge>
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Name</dt>
<dd className="mt-1 text-sm">
{canViewIdentifiableInfo
? participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: "—"
: "REDACTED"}
</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Email</dt>
<dd className="mt-1 text-sm">
{canViewIdentifiableInfo ? participant.email || "—" : "REDACTED"}
</dd>
</div>
</dl>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notes</CardTitle>
<CardDescription>Additional information about this participant</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm whitespace-pre-wrap">
{participant.notes || "No notes available."}
</p>
</CardContent>
</Card>
</div>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,102 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { ParticipantForm, type ParticipantFormValues } from "~/components/participants/participant-form";
import { use } from "react";
import { useToast } from "~/hooks/use-toast";
import { ROLES } from "~/lib/permissions/constants";
function generateIdentifier(studyId: number, count: number) {
// Format: P001, P002, etc. with study prefix
const paddedCount = String(count + 1).padStart(3, '0');
return `P${paddedCount}`;
}
export default function NewParticipantPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const { toast } = useToast();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: participantCount = 0 } = api.participant.getCount.useQuery(
{ studyId },
{ enabled: !!study }
);
const { mutate: createParticipant, isPending: isCreating } = api.participant.create.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Participant added successfully",
});
router.push(`/dashboard/studies/${studyId}/participants`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
function onSubmit(data: ParticipantFormValues) {
createParticipant({
studyId,
...data,
});
}
if (!study) {
return <div>Study not found</div>;
}
// Check if user has permission to add participants
const canAddParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
if (!canAddParticipants) {
return <div>You do not have permission to add participants to this study.</div>;
}
return (
<>
<PageHeader
title="Add Participant"
description={`Add a new participant to ${study.title}`}
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Participant Details</CardTitle>
<CardDescription>
Enter the participant's information. Fields marked with * are required.
</CardDescription>
</CardHeader>
<CardContent>
<ParticipantForm
defaultValues={{
identifier: generateIdentifier(studyId, participantCount),
email: "",
firstName: "",
lastName: "",
notes: "",
status: "active",
}}
onSubmit={onSubmit}
isSubmitting={isCreating}
submitLabel="Add Participant"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { use } from "react";
import { StudyParticipants } from "~/components/studies/study-participants";
export default function ParticipantsPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const { data: study, isLoading } = api.study.getById.useQuery({ id: studyId });
if (isLoading) {
return <div>Loading...</div>;
}
if (!study) {
return <div>Study not found</div>;
}
return (
<>
<PageHeader
title="Participants"
description={`Manage participants for ${study.title}`}
/>
<PageContent>
<StudyParticipants studyId={studyId} role={study.role} />
</PageContent>
</>
);
}

View File

@@ -0,0 +1,50 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
export default function NewStudyPage() {
const router = useRouter();
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
onSuccess: (data) => {
router.push(`/dashboard/studies/${data.id}`);
router.refresh();
},
});
function onSubmit(data: StudyFormValues) {
createStudy(data);
}
return (
<>
<PageHeader
title="New Study"
description="Create a new study"
/>
<PageContent>
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>
Enter the information for your new study.
</CardDescription>
</CardHeader>
<CardContent>
<StudyForm
defaultValues={{ title: "", description: "" }}
onSubmit={onSubmit}
isSubmitting={isCreating}
submitLabel="Create Study"
/>
</CardContent>
</Card>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Plus as PlusIcon } from "lucide-react";
export default function StudiesPage() {
const router = useRouter();
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
if (isLoading) {
return <div>Loading...</div>;
}
return (
<>
<PageHeader
title="Studies"
description="Manage your research studies"
>
<Button
onClick={() => router.push("/dashboard/studies/new")}
size="sm"
>
<PlusIcon className="h-4 w-4 mr-2" />
New Study
</Button>
</PageHeader>
<PageContent>
<div className="grid gap-6">
{!studies || studies.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>No Studies</CardTitle>
<CardDescription>
You haven't created any studies yet. Click the button above to create your first study.
</CardDescription>
</CardHeader>
</Card>
) : (
studies.map((study) => (
<Card
key={study.id}
className="hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => router.push(`/dashboard/studies/${study.id}`)}
>
<CardHeader>
<CardTitle>{study.title}</CardTitle>
<CardDescription>
{study.description || "No description provided"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
Your role: {study.role}
</div>
</CardContent>
</Card>
))
)}
</div>
</PageContent>
</>
);
}

328
src/app/invite/page.tsx Normal file
View File

@@ -0,0 +1,328 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSession, signOut } from "next-auth/react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import Link from "next/link";
import { format } from "date-fns";
import { Logo } from "~/components/logo";
export default function InvitePage() {
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, status } = useSession();
const { toast } = useToast();
const token = searchParams.get("token");
// Don't fetch invitation data until we're authenticated
const { data: invitation, isLoading: isLoadingInvitation } = api.study.getInvitation.useQuery(
{ token: token! },
{
enabled: !!token && status === "authenticated",
retry: false,
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
}
);
const { mutate: acceptInvitation, isLoading: isAccepting } = api.study.acceptInvitation.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "You have successfully joined the study.",
});
router.push(`/dashboard/studies/${invitation?.studyId}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
// Show loading state for missing token
if (!token) {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Invalid Invitation
</CardTitle>
<CardDescription className="text-base">
No invitation token provided. Please check your invitation link.
</CardDescription>
</div>
<Button asChild className="w-full">
<Link href="/dashboard">Return to Dashboard</Link>
</Button>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Show authentication required state
if (status === "unauthenticated") {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Study Invitation
</CardTitle>
<CardDescription className="text-base">
Sign in or create an account to view and accept this invitation.
</CardDescription>
</div>
<div className="space-y-4">
<Button asChild variant="default" className="w-full">
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
Sign In
</Link>
</Button>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
or
</span>
</div>
</div>
<Button asChild variant="outline" className="w-full">
<Link href={`/auth/signup?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
Create Account
</Link>
</Button>
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Show loading state while checking authentication
if (status === "loading") {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Loading...
</CardTitle>
<CardDescription className="text-base">
Please wait while we load your invitation.
</CardDescription>
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Show error state for invalid invitation
if (!invitation) {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Invalid Invitation
</CardTitle>
<CardDescription className="text-base">
This invitation link appears to be invalid or has expired. Please request a new invitation.
</CardDescription>
</div>
<Button asChild className="w-full">
<Link href="/dashboard">Return to Dashboard</Link>
</Button>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Study Invitation
</CardTitle>
<CardDescription className="text-base">
You've been invited to join {invitation.study.title} as a {invitation.role}.
</CardDescription>
</div>
<div className="space-y-4">
<div className="rounded-lg bg-muted p-4 space-y-2">
<div>
<span className="text-sm font-medium text-muted-foreground">Study: </span>
<span className="text-sm">{invitation.study.title}</span>
</div>
{invitation.study.description && (
<div>
<span className="text-sm font-medium text-muted-foreground">Description: </span>
<span className="text-sm">{invitation.study.description}</span>
</div>
)}
<div>
<span className="text-sm font-medium text-muted-foreground">Role: </span>
<span className="text-sm capitalize">{invitation.role}</span>
</div>
<div>
<span className="text-sm font-medium text-muted-foreground">Invited by: </span>
<span className="text-sm">
{invitation.creator.firstName} {invitation.creator.lastName}
</span>
</div>
<div>
<span className="text-sm font-medium text-muted-foreground">Expires: </span>
<span className="text-sm">{format(new Date(invitation.expiresAt), "PPp")}</span>
</div>
</div>
{session.user.email === invitation.email ? (
<Button
className="w-full"
onClick={() => acceptInvitation({ token })}
disabled={isAccepting}
>
{isAccepting ? "Accepting..." : "Accept Invitation"}
</Button>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This invitation was sent to {invitation.email}, but you're signed in with a different
email address ({session.user.email}).
</p>
<div className="flex flex-col gap-2">
<Button asChild variant="default" className="w-full">
<Link href={`/auth/signin?callbackUrl=${encodeURIComponent('/invite?token=' + token)}`}>
Sign in with a different account
</Link>
</Button>
<Button
variant="outline"
className="w-full"
onClick={() => signOut({ callbackUrl: `/invite?token=${token}` })}
>
Sign out
</Button>
</div>
</div>
)}
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,6 +1,5 @@
import "~/styles/globals.css";
import { Inter } from "next/font/google";
import { GeistSans } from 'geist/font/sans';
import { headers } from "next/headers";
@@ -9,12 +8,6 @@ import { cn } from "~/lib/utils";
import { Providers } from "~/components/providers";
import DatabaseCheck from "~/components/db-check";
const inter = Inter({
subsets: ["latin"],
variable: "--font-sans",
});
export const metadata = {
title: "HRIStudio",
description: "A platform for managing human research studies and participant interactions.",
@@ -30,7 +23,7 @@ export default function RootLayout({
<html lang="en" className="h-full">
<body className={cn(
"min-h-screen bg-background font-sans antialiased",
inter.variable
GeistSans.className
)}>
<TRPCReactProvider {...{ headers: headers() }}>
<Providers>

View File

@@ -1,103 +0,0 @@
'use client';
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import Link from "next/link";
import { Loader2 } from "lucide-react";
export function LoginForm({ error }: { error: boolean }) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setIsLoading(true);
const formData = new FormData(e.currentTarget);
const response = await signIn("credentials", {
email: formData.get("email"),
password: formData.get("password"),
redirect: false,
});
setIsLoading(false);
if (!response?.error) {
router.push("/dashboard");
router.refresh();
} else {
router.push("/login?error=CredentialsSignin");
router.refresh();
}
}
return (
<form onSubmit={onSubmit} className="animate-in fade-in-50 slide-in-from-bottom-4">
{error && (
<div className="mb-4 rounded border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<p>Invalid email or password</p>
</div>
)}
<div className="grid gap-4">
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="auth-input"
disabled={isLoading}
required
/>
</div>
<div className="grid gap-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link
href="/forgot-password"
className="text-sm text-muted-foreground transition-colors hover:text-primary"
>
Forgot password?
</Link>
</div>
<Input
id="password"
name="password"
type="password"
autoComplete="current-password"
className="auth-input"
disabled={isLoading}
required
/>
</div>
<Button className="w-full" type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
"Sign In"
)}
</Button>
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href="/register"
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
>
Sign up
</Link>
</div>
</form>
);
}

View File

@@ -1,80 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import {
Card,
CardContent,
CardDescription,
CardTitle,
} from "~/components/ui/card";
import { LoginForm } from "./login-form";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Login | HRIStudio",
description: "Login to your account",
};
export default async function LoginPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
const params = await searchParams;
const error = params?.error ? String(params.error) : null;
const showError = error === "CredentialsSignin";
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Welcome back
</CardTitle>
<CardDescription className="text-base">
Sign in to your account to continue
</CardDescription>
</div>
<LoginForm error={showError} />
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
<p className="mt-6 text-center text-sm text-muted-foreground">
By signing in, you agree to our{" "}
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

537
src/app/onboarding/page.tsx Normal file
View File

@@ -0,0 +1,537 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import Link from "next/link";
import { Logo } from "~/components/logo";
import { StudyForm, type StudyFormValues } from "~/components/studies/study-form";
import { useState } from "react";
import { ArrowLeft, ArrowRight, Bot, Users, Microscope, Beaker, GitBranch } from "lucide-react";
import { LucideIcon } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
interface OnboardingStep {
id: string;
title: string;
description: string;
icon: LucideIcon;
content?: React.ReactNode;
}
// Define the onboarding steps
const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: "welcome",
title: "Welcome to HRIStudio",
description: "Your platform for human-robot interaction research",
icon: Bot,
content: (
<div className="space-y-4">
<p className="text-muted-foreground">
HRIStudio is a comprehensive platform designed to help researchers:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
<li>Design and run Wizard-of-Oz experiments</li>
<li>Manage research participants and data collection</li>
<li>Collaborate with team members in real-time</li>
<li>Analyze and export research data</li>
</ul>
</div>
),
},
{
id: "roles",
title: "Understanding Roles",
description: "Different roles for different responsibilities",
icon: Users,
content: (
<div className="space-y-4">
<p className="text-muted-foreground">
HRIStudio supports various team roles:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
<li><span className="font-medium text-foreground">Owner & Admin:</span> Manage study settings and team</li>
<li><span className="font-medium text-foreground">Principal Investigator:</span> Oversee research design</li>
<li><span className="font-medium text-foreground">Wizard:</span> Control robot behavior during experiments</li>
<li><span className="font-medium text-foreground">Researcher:</span> Analyze data and results</li>
<li><span className="font-medium text-foreground">Observer:</span> View and annotate sessions</li>
</ul>
</div>
),
},
{
id: "studies",
title: "Managing Studies",
description: "Organize your research effectively",
icon: Microscope,
content: (
<div className="space-y-4">
<p className="text-muted-foreground">
Studies are the core of HRIStudio:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
<li>Create multiple studies for different research projects</li>
<li>Invite team members with specific roles</li>
<li>Manage participant recruitment and data</li>
<li>Configure experiment protocols and settings</li>
</ul>
</div>
),
},
{
id: "hierarchy",
title: "Study Structure",
description: "Understanding the experiment hierarchy",
icon: GitBranch,
content: (
<div className="space-y-6">
<div className="relative mx-auto w-full max-w-[400px]">
{/* Study Level */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
>
<div className="font-medium">Study</div>
<div className="text-xs text-muted-foreground">Research Project</div>
</motion.div>
{/* Connecting Line */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 }}
className="absolute left-1/2 top-[60px] h-8 w-px -translate-x-1/2 bg-border"
/>
{/* Experiments Level */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
>
<div className="font-medium">Experiments</div>
<div className="text-xs text-muted-foreground">Study Protocols</div>
</motion.div>
{/* Connecting Line */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.4 }}
className="absolute left-1/2 top-[140px] h-8 w-px -translate-x-1/2 bg-border"
/>
{/* Trials Level */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
>
<div className="font-medium">Trials</div>
<div className="text-xs text-muted-foreground">Individual Sessions</div>
</motion.div>
{/* Connecting Line */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.6 }}
className="absolute left-1/2 top-[220px] h-8 w-px -translate-x-1/2 bg-border"
/>
{/* Steps Level */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.7 }}
className="mx-auto mb-4 w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
>
<div className="font-medium">Steps</div>
<div className="text-xs text-muted-foreground">Trial Procedures</div>
</motion.div>
{/* Connecting Line */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.8 }}
className="absolute left-1/2 bottom-[60px] h-8 w-px -translate-x-1/2 bg-border"
/>
{/* Actions Level */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.9 }}
className="mx-auto w-48 rounded-lg border bg-card p-3 text-center shadow-sm"
>
<div className="font-medium">Actions</div>
<div className="text-xs text-muted-foreground">Individual Operations</div>
</motion.div>
</div>
<div className="text-sm text-muted-foreground">
<p>The experiment structure flows from top to bottom:</p>
<ul className="mt-2 list-inside list-disc space-y-1">
<li><span className="font-medium text-foreground">Study:</span> Contains experiments and team members</li>
<li><span className="font-medium text-foreground">Experiments:</span> Define reusable protocols</li>
<li><span className="font-medium text-foreground">Trials:</span> Individual sessions with participants</li>
<li><span className="font-medium text-foreground">Steps:</span> Ordered procedures within a trial</li>
<li><span className="font-medium text-foreground">Actions:</span> Specific operations (movement, speech, etc.)</li>
</ul>
</div>
</div>
),
},
{
id: "experiments",
title: "Running Experiments",
description: "Conduct Wizard-of-Oz studies seamlessly",
icon: Beaker,
content: (
<div className="space-y-4">
<p className="text-muted-foreground">
Design and execute experiments with ease:
</p>
<ul className="list-disc list-inside space-y-2 text-muted-foreground">
<li>Create reusable experiment templates</li>
<li>Define robot behaviors and interactions</li>
<li>Record and annotate sessions in real-time</li>
<li>Collect and analyze participant data</li>
</ul>
</div>
),
},
{
id: "setup",
title: "Let's Get Started",
description: "Create your first study or join an existing one",
icon: Bot,
},
];
// Update slideVariants
const slideVariants = {
enter: (direction: number) => ({
x: direction > 0 ? 50 : -50,
opacity: 0
}),
center: {
zIndex: 1,
x: 0,
opacity: 1
},
exit: (direction: number) => ({
zIndex: 0,
x: direction < 0 ? 50 : -50,
opacity: 0
})
};
export default function OnboardingPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { data: session, status } = useSession();
const { toast } = useToast();
const [currentStep, setCurrentStep] = useState(0);
const [direction, setDirection] = useState(0);
// Get invitation token if it exists
const token = searchParams.get("token");
// Fetch invitation if token exists
const { data: invitation } = api.study.getInvitation.useQuery(
{ token: token! },
{
enabled: !!token && status === "authenticated",
retry: false,
}
);
// Mutation for accepting invitation
const { mutate: acceptInvitation, isPending: isAccepting } = api.study.acceptInvitation.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "You have successfully joined the study.",
});
router.push(`/dashboard/studies/${invitation?.studyId}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
// Mutation for creating a new study
const { mutate: createStudy, isPending: isCreating } = api.study.create.useMutation({
onSuccess: (data) => {
toast({
title: "Success",
description: "Your study has been created successfully.",
});
router.push(`/dashboard/studies/${data.id}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
// Handle study creation
function onCreateStudy(data: StudyFormValues) {
createStudy(data);
}
// Navigation functions
const nextStep = () => {
if (currentStep < ONBOARDING_STEPS.length - 1) {
setDirection(1);
setCurrentStep(currentStep + 1);
}
};
const prevStep = () => {
if (currentStep > 0) {
setDirection(-1);
setCurrentStep(currentStep - 1);
}
};
// Ensure currentStep is within bounds
const safeStep = Math.min(Math.max(0, currentStep), ONBOARDING_STEPS.length - 1);
const currentStepData = ONBOARDING_STEPS[safeStep]!;
const Icon = currentStepData.icon;
// Show loading state while checking authentication
if (status === "loading") {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Loading...
</CardTitle>
<CardDescription className="text-base">
Please wait while we set up your account.
</CardDescription>
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Redirect to sign in if not authenticated
if (status === "unauthenticated") {
router.push("/auth/signin");
return null;
}
// If user has an invitation and we're on the final step
if (token && invitation && safeStep === ONBOARDING_STEPS.length - 1) {
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Join {invitation.study.title}
</CardTitle>
<CardDescription className="text-base">
You've been invited to join as a {invitation.role}.
</CardDescription>
</div>
<div className="space-y-4">
{session?.user.email === invitation.email ? (
<Button
className="w-full"
onClick={() => acceptInvitation({ token })}
disabled={isAccepting}
>
{isAccepting ? "Joining Study..." : "Join Study"}
</Button>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
This invitation was sent to {invitation.email}, but you're signed in with a different
email address ({session?.user.email}).
</p>
<Button
variant="outline"
className="w-full"
onClick={() => router.push("/auth/signin")}
>
Sign in with correct account
</Button>
</div>
)}
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[1000px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="relative p-6 md:p-8">
<div className="mb-6 space-y-2">
<motion.div
className="mb-8 flex h-12 w-12 items-center justify-center rounded-lg bg-primary/10"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<Icon className="h-6 w-6 text-primary" />
</motion.div>
<CardTitle className="text-2xl font-bold tracking-tight">
{currentStepData.title}
</CardTitle>
<CardDescription className="text-base">
{currentStepData.description}
</CardDescription>
</div>
<div className="relative h-[280px]">
<AnimatePresence mode="wait" custom={direction}>
<motion.div
key={currentStepData.id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 }
}}
className="absolute inset-0"
>
<div className="relative h-full">
<div className="h-full overflow-y-auto pr-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-muted-foreground/20 hover:scrollbar-thumb-muted-foreground/30">
{safeStep === ONBOARDING_STEPS.length - 1 ? (
<StudyForm
defaultValues={{ title: "", description: "" }}
onSubmit={onCreateStudy}
isSubmitting={isCreating}
submitLabel="Create Study"
/>
) : (
<div className="space-y-6">
{currentStepData.content}
</div>
)}
</div>
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="mt-6 flex justify-between pt-4">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button
variant="outline"
onClick={prevStep}
disabled={safeStep === 0}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Back
</Button>
</motion.div>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Button onClick={nextStep}>
Next
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</motion.div>
</div>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
<div className="absolute bottom-8 left-8 right-8">
<div className="flex justify-between gap-2">
{ONBOARDING_STEPS.map((step, index) => (
<div
key={step.id}
className={`h-1 flex-1 rounded-full transition-colors ${
index <= safeStep ? "bg-primary" : "bg-border"
}`}
/>
))}
</div>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -19,10 +19,10 @@ export default async function Home() {
{!session && (
<>
<Button variant="ghost" asChild>
<Link href="/login">Sign In</Link>
<Link href="/auth/signin">Sign In</Link>
</Button>
<Button asChild>
<Link href="/register">Sign Up</Link>
<Link href="/auth/signup">Sign Up</Link>
</Button>
</>
)}
@@ -47,7 +47,7 @@ export default async function Home() {
<div className="mt-8 flex flex-col sm:flex-row gap-4">
{!session ? (
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href="/register">Get Started</Link>
<Link href="/auth/signup">Get Started</Link>
</Button>
) : (
<Button size="lg" className="w-full sm:w-auto" asChild>

View File

@@ -1,142 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardTitle
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Logo } from "~/components/logo";
export const metadata: Metadata = {
title: "Register | HRIStudio",
description: "Create a new account",
};
export default async function RegisterPage() {
const session = await getServerAuthSession();
if (session) {
redirect("/dashboard");
}
return (
<div className="auth-gradient relative flex min-h-screen items-center justify-center px-4">
<Logo
className="absolute left-4 top-4 text-lg transition-colors hover:text-primary md:left-8 md:top-8"
iconClassName="text-primary"
/>
<div className="w-full max-w-[800px] px-4 py-8">
<Card className="auth-card shadow-xl transition-shadow hover:shadow-lg">
<CardContent className="grid p-0 md:grid-cols-2">
<div className="p-6 md:p-8">
<div className="mb-6 space-y-2">
<CardTitle className="text-2xl font-bold tracking-tight">
Create an account
</CardTitle>
<CardDescription className="text-base">
Enter your details to get started
</CardDescription>
</div>
<form action="/api/auth/register" method="POST" className="animate-in fade-in-50 slide-in-from-bottom-4">
<div className="grid gap-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid gap-2">
<Label htmlFor="firstName">First Name</Label>
<Input
id="firstName"
name="firstName"
placeholder="John"
autoComplete="given-name"
className="auth-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="lastName">Last Name</Label>
<Input
id="lastName"
name="lastName"
placeholder="Doe"
autoComplete="family-name"
className="auth-input"
required
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
className="auth-input"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
name="password"
type="password"
autoComplete="new-password"
className="auth-input"
required
/>
<p className="text-sm text-muted-foreground">
Must be at least 8 characters long
</p>
</div>
<Button className="w-full" type="submit">
Create account
</Button>
</div>
<div className="mt-4 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/login"
className="font-medium text-primary underline-offset-4 transition-colors hover:underline"
>
Sign in
</Link>
</div>
</form>
</div>
<div className="relative hidden h-full md:block">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary/20 to-secondary/10 rounded-r-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<Logo
className="pointer-events-none"
iconClassName="h-32 w-32 mr-0 text-primary/40"
textClassName="sr-only"
/>
</div>
</div>
</CardContent>
</Card>
<p className="mt-6 text-center text-sm text-muted-foreground">
By creating an account, you agree to our{" "}
<Link href="/terms" className="underline underline-offset-4 hover:text-primary">
Terms of Service
</Link>{" "}
and{" "}
<Link href="/privacy" className="underline underline-offset-4 hover:text-primary">
Privacy Policy
</Link>
.
</p>
</div>
</div>
);
}

View File

@@ -1,15 +0,0 @@
export default async function StudyPage({ params }: { params: { id: string } }) {
const study = await db.query.studies.findFirst({
where: (studies, { eq }) => eq(studies.id, params.id),
with: { experiments: true }
})
return (
<div className="max-w-6xl mx-auto p-6">
<StudyHeader study={study} />
<Suspense fallback={<ExperimentListSkeleton />}>
<ExperimentList studyId={params.id} />
</Suspense>
</div>
)
}

View File

@@ -2,7 +2,7 @@
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -16,29 +16,46 @@ import {
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useToast } from "~/components/ui/use-toast";
import { useToast } from "~/hooks/use-toast";
import React from "react";
const formSchema = z.object({
const signInSchema = z.object({
email: z.string().email(),
password: z.string().min(8),
});
type FormValues = z.infer<typeof formSchema>;
type SignInValues = z.infer<typeof signInSchema>;
export function SignInForm() {
interface SignInFormProps {
error?: boolean;
}
export function SignInForm({ error }: SignInFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
// Show error toast if credentials are invalid
React.useEffect(() => {
if (error) {
toast({
title: "Error",
description: "Invalid email or password",
variant: "destructive",
});
}
}, [error, toast]);
const form = useForm<SignInValues>({
resolver: zodResolver(signInSchema),
defaultValues: {
email: "",
email: searchParams.get("email") ?? "",
password: "",
},
});
async function onSubmit(data: FormValues) {
async function onSubmit(data: SignInValues) {
setIsLoading(true);
try {
@@ -50,20 +67,21 @@ export function SignInForm() {
if (result?.error) {
toast({
variant: "destructive",
title: "Error",
description: "Invalid email or password",
variant: "destructive",
});
return;
}
const callbackUrl = searchParams.get("callbackUrl") ?? "/dashboard";
router.push(callbackUrl);
router.refresh();
router.push("/");
} catch (error) {
toast({
variant: "destructive",
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
} finally {
setIsLoading(false);
@@ -91,6 +109,7 @@ export function SignInForm() {
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
@@ -109,6 +128,7 @@ export function SignInForm() {
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in"}
</Button>

View File

@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter, useSearchParams } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { useToast } from "~/hooks/use-toast";
import React from "react";
const signUpSchema = z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email(),
password: z.string().min(8),
});
type SignUpValues = z.infer<typeof signUpSchema>;
interface SignUpFormProps {
error?: boolean;
}
export function SignUpForm({ error }: SignUpFormProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
// Show error toast if credentials are invalid
React.useEffect(() => {
if (error) {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
}
}, [error, toast]);
const form = useForm<SignUpValues>({
resolver: zodResolver(signUpSchema),
defaultValues: {
firstName: "",
lastName: "",
email: searchParams.get("email") ?? "",
password: "",
},
});
async function onSubmit(data: SignUpValues) {
setIsLoading(true);
try {
const formData = new FormData();
formData.append("firstName", data.firstName);
formData.append("lastName", data.lastName);
formData.append("email", data.email);
formData.append("password", data.password);
const response = await fetch("/api/auth/register", {
method: "POST",
body: formData,
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error ?? "Something went wrong");
}
const result = await signIn("credentials", {
redirect: false,
email: data.email,
password: data.password,
});
if (result?.error) {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
return;
}
// Get the invitation token if it exists
const token = searchParams.get("token");
// Redirect to onboarding with token if it exists
const onboardingUrl = token
? `/onboarding?token=${token}`
: "/onboarding";
router.push(onboardingUrl);
router.refresh();
} catch (error) {
if (error instanceof Error) {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
} else {
toast({
title: "Error",
description: "Something went wrong. Please try again.",
variant: "destructive",
});
}
} finally {
setIsLoading(false);
}
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input
placeholder="John"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input
placeholder="Doe"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="name@example.com"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="Enter your password"
{...field}
disabled={isLoading}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Create account"}
</Button>
</form>
</Form>
);
}

View File

@@ -4,10 +4,14 @@ import {
Beaker,
Home,
Settings2,
User
User,
Microscope,
Users,
Plus
} from "lucide-react"
import * as React from "react"
import { useSession } from "next-auth/react"
import { useStudy } from "~/components/providers/study-provider"
import { StudySwitcher } from "~/components/auth/study-switcher"
import {
@@ -20,18 +24,23 @@ import {
import { NavMain } from "~/components/navigation/nav-main"
import { NavUser } from "~/components/navigation/nav-user"
const data = {
navMain: [
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession()
const { activeStudy } = useStudy()
if (!session) return null
// Base navigation items that are always shown
const baseNavItems = [
{
title: "Overview",
url: "/dashboard",
icon: Home,
isActive: true,
},
{
title: "Studies",
url: "/dashboard/studies",
icon: Beaker,
icon: Microscope,
items: [
{
title: "All Studies",
@@ -43,6 +52,33 @@ const data = {
},
],
},
]
// Study-specific navigation items that are only shown when a study is active
const studyNavItems = activeStudy
? [
{
title: "Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
icon: Users,
items: [
{
title: "All Participants",
url: `/dashboard/studies/${activeStudy.id}/participants`,
},
{
title: "Add Participant",
url: `/dashboard/studies/${activeStudy.id}/participants/new`,
// Only show if user is admin
hidden: activeStudy.role !== "ADMIN",
},
],
},
]
: []
// Settings navigation items
const settingsNavItems = [
{
title: "Settings",
url: "/dashboard/settings",
@@ -63,12 +99,9 @@ const data = {
},
],
},
],
}
]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
const { data: session } = useSession()
if (!session) return null
const navItems = [...baseNavItems, ...studyNavItems, ...settingsNavItems]
return (
<Sidebar
@@ -81,7 +114,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<StudySwitcher />
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavMain items={navItems} />
</SidebarContent>
<SidebarFooter>
<NavUser />

View File

@@ -31,6 +31,39 @@ export function BreadcrumbNav() {
label: "Create Study",
current: true,
})
} else if (paths[2]) {
items.push({
label: "Study Details",
href: `/dashboard/studies/${paths[2]}`,
current: paths.length === 3,
})
if (paths[3] === "participants") {
items.push({
label: "Participants",
href: `/dashboard/studies/${paths[2]}/participants`,
current: paths.length === 4,
})
if (paths[4] === "new") {
items.push({
label: "Add Participant",
current: true,
})
} else if (paths[4]) {
items.push({
label: "Participant Details",
current: paths.length === 5,
})
if (paths[5] === "edit") {
items.push({
label: "Edit",
current: true,
})
}
}
}
}
}
@@ -41,11 +74,11 @@ export function BreadcrumbNav() {
return (
<nav aria-label="breadcrumb">
<ol className="flex items-center gap-2">
<ol className="flex items-center">
{breadcrumbs.map((item, index) => (
<li key={item.label} className="flex items-center">
{index > 0 && (
<span role="presentation" aria-hidden="true" className="mx-2 text-muted-foreground">
<span role="presentation" aria-hidden="true" className="mx-1 text-muted-foreground">
/
</span>
)}

View File

@@ -63,7 +63,7 @@ export function NavUser() {
<div className="relative size-full overflow-hidden rounded-lg">
<Image
src={session.user.image}
alt={session.user.name ?? "User"}
alt={session.user.firstName ?? "User"}
fill
sizes="32px"
className="object-cover"

View File

@@ -0,0 +1,192 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
FormDescription,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
const participantFormSchema = z.object({
identifier: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
firstName: z.string().optional(),
lastName: z.string().optional(),
notes: z.string().optional(),
status: z.enum(["active", "inactive", "completed", "withdrawn"]).default("active"),
});
export type ParticipantFormValues = z.infer<typeof participantFormSchema>;
interface ParticipantFormProps {
defaultValues?: Partial<ParticipantFormValues>;
onSubmit: (data: ParticipantFormValues) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function ParticipantForm({
defaultValues = {
identifier: "",
email: "",
firstName: "",
lastName: "",
notes: "",
status: "active",
},
onSubmit,
isSubmitting = false,
submitLabel = "Save",
}: ParticipantFormProps) {
const form = useForm<ParticipantFormValues>({
resolver: zodResolver(participantFormSchema),
defaultValues,
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="grid gap-6 sm:grid-cols-2">
<FormField
control={form.control}
name="identifier"
render={({ field }) => (
<FormItem>
<FormLabel>Identifier</FormLabel>
<FormControl>
<Input
placeholder="Enter participant identifier"
{...field}
readOnly
className="bg-muted"
/>
</FormControl>
<FormDescription>
Auto-generated unique identifier
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select participant status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="withdrawn">Withdrawn</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>First Name</FormLabel>
<FormControl>
<Input placeholder="Enter first name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Last Name</FormLabel>
<FormControl>
<Input placeholder="Enter last name" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email (Optional)</FormLabel>
<FormControl>
<Input
type="email"
placeholder="Enter participant email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="sm:col-span-2">
<FormField
control={form.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Notes (Optional)</FormLabel>
<FormControl>
<Textarea
placeholder="Enter any notes about this participant"
className="h-20"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,131 @@
"use client";
import { useRouter } from "next/navigation";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
const createStudySchema = z.object({
title: z.string().min(1, "Title is required").max(256, "Title is too long"),
description: z.string().optional(),
});
type FormData = z.infer<typeof createStudySchema>;
export function CreateStudyForm() {
const router = useRouter();
const { toast } = useToast();
const { data: session, status } = useSession();
const form = useForm<FormData>({
resolver: zodResolver(createStudySchema),
defaultValues: {
title: "",
description: "",
},
});
const createStudy = api.study.create.useMutation({
onSuccess: (study) => {
toast({
title: "Study created",
description: "Your study has been created successfully.",
});
router.push(`/dashboard/studies/${study.id}`);
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const onSubmit = (data: FormData) => {
if (status !== "authenticated") {
toast({
title: "Error",
description: "You must be logged in to create a study.",
variant: "destructive",
});
return;
}
createStudy.mutate(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter study title" {...field} />
</FormControl>
<FormDescription>
A descriptive name for your study.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter study description"
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A brief description of your study and its objectives.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
>
Cancel
</Button>
<Button
type="submit"
disabled={createStudy.isLoading || status !== "authenticated"}
>
{createStudy.isLoading ? "Creating..." : "Create Study"}
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,62 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { Trash2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { api } from "~/trpc/react";
interface DeleteStudyButtonProps {
id: number;
}
export function DeleteStudyButton({ id }: DeleteStudyButtonProps) {
const [open, setOpen] = useState(false);
const router = useRouter();
const { mutate: deleteStudy, isLoading } = api.study.delete.useMutation({
onSuccess: () => {
router.push("/studies");
router.refresh();
},
});
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="destructive">
<Trash2 className="w-4 h-4 mr-2" />
Delete
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete the study
and all associated data.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteStudy({ id })}
disabled={isLoading}
>
{isLoading ? "Deleting..." : "Delete"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
import { format } from "date-fns";
import { Activity, User, UserPlus, Settings, FileEdit } from "lucide-react";
interface StudyActivityProps {
studyId: number;
role: string;
}
interface ActivityItem {
id: number;
type: "member_added" | "member_role_changed" | "study_updated" | "participant_added" | "participant_updated";
description: string;
userId: string;
userName: string;
createdAt: Date;
}
function getActivityIcon(type: ActivityItem["type"]) {
switch (type) {
case "member_added":
return <UserPlus className="h-4 w-4" />;
case "member_role_changed":
return <Settings className="h-4 w-4" />;
case "study_updated":
return <FileEdit className="h-4 w-4" />;
case "participant_added":
case "participant_updated":
return <User className="h-4 w-4" />;
default:
return <Activity className="h-4 w-4" />;
}
}
export function StudyActivity({ studyId, role }: StudyActivityProps) {
const { data: activities } = api.study.getActivities.useQuery({ studyId });
return (
<Card>
<CardHeader>
<CardTitle>Activity Log</CardTitle>
<CardDescription>Recent activity in this study</CardDescription>
</CardHeader>
<CardContent>
{!activities || activities.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No activity recorded yet
</div>
) : (
<div className="space-y-8">
{activities.map((activity) => (
<div key={activity.id} className="flex gap-4">
<div className="mt-1">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-muted">
{getActivityIcon(activity.type)}
</div>
</div>
<div className="flex-1 space-y-1">
<p className="text-sm font-medium leading-none">
{activity.userName}
</p>
<p className="text-sm text-muted-foreground">
{activity.description}
</p>
<p className="text-xs text-muted-foreground">
{format(new Date(activity.createdAt), "PPpp")}
</p>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { Card, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
interface StudyCardProps {
id: number;
title: string;
description?: string | null;
role: string;
}
export function StudyCard({ id, title, description, role }: StudyCardProps) {
return (
<Link href={`/studies/${id}`}>
<Card className="hover:bg-muted/50 transition-colors">
<CardHeader>
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
<div className="text-sm text-muted-foreground mt-2">
Role: {role}
</div>
</CardHeader>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
const studyFormSchema = z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
});
export type StudyFormValues = z.infer<typeof studyFormSchema>;
interface StudyFormProps {
defaultValues?: StudyFormValues;
onSubmit: (data: StudyFormValues) => void;
isSubmitting?: boolean;
submitLabel?: string;
}
export function StudyForm({
defaultValues = {
title: "",
description: "",
},
onSubmit,
isSubmitting = false,
submitLabel = "Save",
}: StudyFormProps) {
const form = useForm<StudyFormValues>({
resolver: zodResolver(studyFormSchema),
defaultValues,
});
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="title"
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder="Enter study title" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Enter study description"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</form>
</Form>
);
}

View File

@@ -0,0 +1,372 @@
"use client";
import { useState } from "react";
import { useSession } from "next-auth/react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import { ROLES } from "~/lib/permissions/constants";
import { Plus, UserPlus, Crown } from "lucide-react";
interface StudyMembersProps {
studyId: number;
role: string;
}
export function StudyMembers({ studyId, role }: StudyMembersProps) {
const { data: session } = useSession();
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [isTransferOpen, setIsTransferOpen] = useState(false);
const [transferToUserId, setTransferToUserId] = useState<string | null>(null);
const [email, setEmail] = useState("");
const [selectedRole, setSelectedRole] = useState(ROLES.RESEARCHER);
const { toast } = useToast();
const { data: members, refetch: refetchMembers } = api.study.getMembers.useQuery({ studyId });
const { data: pendingInvitations, refetch: refetchInvitations } = api.study.getPendingInvitations.useQuery({ studyId });
const { mutate: inviteMember, isPending: isInviting } = api.study.inviteMember.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Member invited successfully",
});
setIsInviteOpen(false);
setEmail("");
setSelectedRole(ROLES.RESEARCHER);
refetchMembers();
refetchInvitations();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: transferOwnership, isPending: isTransferring } = api.study.transferOwnership.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Study ownership transferred successfully",
});
setIsTransferOpen(false);
setTransferToUserId(null);
refetchMembers();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: revokeInvitation } = api.study.revokeInvitation.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Invitation revoked successfully",
});
refetchInvitations();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: updateMemberRole } = api.study.updateMemberRole.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Member role updated successfully",
});
refetchMembers();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canManageMembers = role.toUpperCase() === ROLES.OWNER.toUpperCase() || role.toUpperCase() === ROLES.ADMIN.toUpperCase();
const isOwner = role.toUpperCase() === ROLES.OWNER.toUpperCase();
// Get available roles based on current user's role
const getAvailableRoles = (userRole: string) => {
const roleHierarchy = {
[ROLES.OWNER.toUpperCase()]: [ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
[ROLES.ADMIN.toUpperCase()]: [ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
[ROLES.PRINCIPAL_INVESTIGATOR.toUpperCase()]: [ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD],
};
return roleHierarchy[userRole.toUpperCase()] ?? [];
};
const availableRoles = getAvailableRoles(role);
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Members</CardTitle>
<CardDescription>Manage members and their roles</CardDescription>
</div>
{canManageMembers && (
<Dialog open={isInviteOpen} onOpenChange={setIsInviteOpen}>
<DialogTrigger asChild>
<Button size="sm">
<UserPlus className="h-4 w-4 mr-2" />
Invite Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite New Member</DialogTitle>
<DialogDescription>
Enter the email address of the person you want to invite to this study.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="email">Email address</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="role">Role</Label>
<Select
value={selectedRole}
onValueChange={setSelectedRole}
defaultValue={availableRoles[0]}
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{availableRoles.map((availableRole) => (
<SelectItem key={availableRole} value={availableRole}>
{availableRole === ROLES.PRINCIPAL_INVESTIGATOR ? "Principal Investigator" : availableRole}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={() => inviteMember({ studyId, email, role: selectedRole })}
disabled={isInviting}
>
{isInviting ? "Inviting..." : "Send Invite"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{!members || members.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No members found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
{canManageMembers && <TableHead>Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{members.map((member) => (
<TableRow key={member.userId}>
<TableCell>{member.name}</TableCell>
<TableCell>{member.email}</TableCell>
<TableCell>
{canManageMembers && member.role.toUpperCase() !== ROLES.OWNER.toUpperCase() ? (
<Select
value={member.role}
onValueChange={(newRole) =>
updateMemberRole({
studyId,
userId: member.userId,
role: newRole,
})
}
disabled={member.userId === session?.user.id}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={ROLES.ADMIN}>Admin</SelectItem>
<SelectItem value={ROLES.PRINCIPAL_INVESTIGATOR}>Principal Investigator</SelectItem>
<SelectItem value={ROLES.RESEARCHER}>Researcher</SelectItem>
<SelectItem value={ROLES.OBSERVER}>Observer</SelectItem>
<SelectItem value={ROLES.WIZARD}>Wizard</SelectItem>
</SelectContent>
</Select>
) : (
<div className="px-3 py-2">{member.role}</div>
)}
</TableCell>
{canManageMembers && (
<TableCell>
{isOwner && member.userId !== session?.user.id && (
<AlertDialog open={isTransferOpen && transferToUserId === member.userId} onOpenChange={(open) => {
setIsTransferOpen(open);
if (!open) setTransferToUserId(null);
}}>
<Button
variant="outline"
size="sm"
onClick={() => {
setTransferToUserId(member.userId);
setIsTransferOpen(true);
}}
>
<Crown className="h-4 w-4 mr-2" />
Transfer Ownership
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Transfer Study Ownership</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to transfer ownership to {member.name}? This action cannot be undone.
You will become an admin of the study.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
transferOwnership({
studyId,
newOwnerId: member.userId,
});
}}
disabled={isTransferring}
>
{isTransferring ? "Transferring..." : "Transfer Ownership"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{canManageMembers && (
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>Manage outstanding invitations to join the study</CardDescription>
</CardHeader>
<CardContent>
{!pendingInvitations || pendingInvitations.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No pending invitations
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Invited By</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pendingInvitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell>{invitation.email}</TableCell>
<TableCell>{invitation.role}</TableCell>
<TableCell>{invitation.creatorName}</TableCell>
<TableCell>{new Date(invitation.expiresAt).toLocaleDateString()}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => revokeInvitation({ studyId, invitationId: invitation.id })}
>
Revoke
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { api } from "~/trpc/react";
import { useToast } from "~/hooks/use-toast";
import { Plus, Trash2 } from "lucide-react";
import { Badge } from "~/components/ui/badge";
interface StudyMetadataProps {
studyId: number;
role: string;
}
export function StudyMetadata({ studyId, role }: StudyMetadataProps) {
const [isAddOpen, setIsAddOpen] = useState(false);
const [key, setKey] = useState("");
const [value, setValue] = useState("");
const { toast } = useToast();
const { data: metadata, refetch } = api.study.getMetadata.useQuery({ studyId });
const { mutate: addMetadata, isPending: isAdding } = api.study.addMetadata.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Metadata added successfully",
});
setIsAddOpen(false);
setKey("");
setValue("");
refetch();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const { mutate: deleteMetadata } = api.study.deleteMetadata.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Metadata deleted successfully",
});
refetch();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canManageMetadata = role === "ADMIN";
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Metadata</CardTitle>
<CardDescription>Custom fields and tags for this study</CardDescription>
</div>
{canManageMetadata && (
<Dialog open={isAddOpen} onOpenChange={setIsAddOpen}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Field
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Metadata Field</DialogTitle>
<DialogDescription>
Add a new custom field or tag to this study.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="key">Field Name</Label>
<Input
id="key"
placeholder="Enter field name"
value={key}
onChange={(e) => setKey(e.target.value)}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="value">Value</Label>
<Input
id="value"
placeholder="Enter value"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() => addMetadata({ studyId, key, value })}
disabled={isAdding}
>
{isAdding ? "Adding..." : "Add Field"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
</CardHeader>
<CardContent>
{!metadata || metadata.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No metadata fields found
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Field</TableHead>
<TableHead>Value</TableHead>
{canManageMetadata && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{metadata.map((item) => (
<TableRow key={item.key}>
<TableCell className="font-medium">{item.key}</TableCell>
<TableCell>
<Badge variant="secondary">{item.value}</Badge>
</TableCell>
{canManageMetadata && (
<TableCell>
<Button
variant="ghost"
size="sm"
onClick={() => deleteMetadata({ studyId, key: item.key })}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete</span>
</Button>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { api } from "~/trpc/react";
import { Users, Calendar, Clock } from "lucide-react";
interface StudyOverviewProps {
study: {
id: number;
title: string;
description: string | null;
role: string;
};
}
export function StudyOverview({ study }: StudyOverviewProps) {
const { data: participantCount } = api.participant.getCount.useQuery({ studyId: study.id });
return (
<div className="grid gap-4">
{/* Basic Information */}
<Card>
<CardHeader>
<CardTitle>Study Details</CardTitle>
<CardDescription>Basic information about the study</CardDescription>
</CardHeader>
<CardContent>
<dl className="grid gap-4 sm:grid-cols-2">
<div>
<dt className="text-sm font-medium text-muted-foreground">Your Role</dt>
<dd className="mt-1 text-sm">{study.role}</dd>
</div>
<div>
<dt className="text-sm font-medium text-muted-foreground">Description</dt>
<dd className="mt-1 text-sm">{study.description || "No description provided"}</dd>
</div>
</dl>
</CardContent>
</Card>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Participants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{participantCount ?? 0}</div>
<p className="text-xs text-muted-foreground">
Active participants in study
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Last Activity</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"></div>
<p className="text-xs text-muted-foreground">
Most recent update
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Study Duration</CardTitle>
<Calendar className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold"></div>
<p className="text-xs text-muted-foreground">
Days since creation
</p>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useRouter } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Badge } from "~/components/ui/badge";
import { api } from "~/trpc/react";
import { Plus as PlusIcon, Eye, EyeOff } from "lucide-react";
import { ROLES } from "~/lib/permissions/constants";
import { Switch } from "~/components/ui/switch";
import { Label } from "~/components/ui/label";
import { useState } from "react";
interface StudyParticipantsProps {
studyId: number;
role: string;
}
export function StudyParticipants({ studyId, role }: StudyParticipantsProps) {
const router = useRouter();
const { data: participants, isLoading } = api.participant.getByStudyId.useQuery({ studyId });
const [showIdentifiable, setShowIdentifiable] = useState(false);
const canViewIdentifiableInfo = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(role.toLowerCase());
const canManageParticipants = [ROLES.OWNER, ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR]
.map(r => r.toLowerCase())
.includes(role.toLowerCase());
if (isLoading) {
return <div>Loading...</div>;
}
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Study Participants</CardTitle>
{!canViewIdentifiableInfo ? (
<CardDescription className="text-yellow-600">
Personal information is redacted based on your role.
</CardDescription>
) : (
<CardDescription>
{showIdentifiable
? "Showing personal information."
: "Personal information is hidden."}
</CardDescription>
)}
</div>
<div className="flex items-center gap-4">
{canViewIdentifiableInfo && (
<div className="flex items-center gap-2 border rounded-lg p-2 bg-muted/50">
<div className="flex items-center gap-2">
<Switch
id="show-identifiable"
checked={showIdentifiable}
onCheckedChange={setShowIdentifiable}
/>
<Label
htmlFor="show-identifiable"
className="text-sm font-medium flex items-center gap-2 cursor-pointer select-none"
>
{showIdentifiable ? (
<>
<Eye className="h-4 w-4" />
Personal Info Visible
</>
) : (
<>
<EyeOff className="h-4 w-4" />
Personal Info Hidden
</>
)}
</Label>
</div>
</div>
)}
{canManageParticipants && (
<Button
size="sm"
onClick={() => router.push(`/dashboard/studies/${studyId}/participants/new`)}
>
<PlusIcon className="h-4 w-4 mr-2" />
Add Participant
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
{!participants || participants.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
No participants have been added to this study yet.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Status</TableHead>
{(canViewIdentifiableInfo && showIdentifiable) && (
<>
<TableHead>Name</TableHead>
<TableHead>Email</TableHead>
</>
)}
<TableHead>Notes</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{participants.map((participant) => (
<TableRow
key={participant.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() =>
router.push(`/dashboard/studies/${studyId}/participants/${participant.id}`)
}
>
<TableCell>{participant.identifier || "—"}</TableCell>
<TableCell>
<Badge
variant={
participant.status === "active"
? "default"
: participant.status === "completed"
? "secondary"
: "outline"
}
>
{participant.status}
</Badge>
</TableCell>
{(canViewIdentifiableInfo && showIdentifiable) && (
<>
<TableCell>
{participant.firstName && participant.lastName
? `${participant.firstName} ${participant.lastName}`
: "—"}
</TableCell>
<TableCell>{participant.email || "—"}</TableCell>
</>
)}
<TableCell className="max-w-[200px] truncate">
{participant.notes || "—"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
);
}

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

@@ -5,21 +5,26 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "underline-offset-4 hover:underline text-primary",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 py-2 px-4",
sm: "h-9 px-3 rounded-md",
lg: "h-11 px-8 rounded-md",
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "~/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

35
src/env.ts Normal file
View File

@@ -0,0 +1,35 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
NODE_ENV: z.enum(["development", "test", "production"]),
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(1),
NEXTAUTH_URL: z.string().url(),
// Email configuration
SMTP_HOST: z.string(),
SMTP_PORT: z.string().transform(Number),
SMTP_USER: z.string(),
SMTP_PASS: z.string(),
EMAIL_FROM_NAME: z.string(),
EMAIL_FROM_ADDRESS: z.string().email(),
},
client: {
// Add client-side env vars here
},
runtimeEnv: {
NODE_ENV: process.env.NODE_ENV,
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
// Email configuration
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASS: process.env.SMTP_PASS,
EMAIL_FROM_NAME: process.env.EMAIL_FROM_NAME,
EMAIL_FROM_ADDRESS: process.env.EMAIL_FROM_ADDRESS,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
});

View File

@@ -1,128 +1,187 @@
export const PERMISSIONS = {
// Study permissions
// Study Management
CREATE_STUDY: "create_study",
EDIT_STUDY: "edit_study",
DELETE_STUDY: "delete_study",
TRANSFER_OWNERSHIP: "transfer_ownership",
VIEW_STUDY: "view_study",
MANAGE_STUDY_METADATA: "manage_study_metadata",
// Participant permissions
VIEW_PARTICIPANT_NAMES: "view_participant_names",
CREATE_PARTICIPANT: "create_participant",
// Participant Management
ADD_PARTICIPANT: "add_participant",
EDIT_PARTICIPANT: "edit_participant",
DELETE_PARTICIPANT: "delete_participant",
VIEW_PARTICIPANT_IDENTIFIABLE: "view_participant_identifiable",
VIEW_PARTICIPANT_ANONYMIZED: "view_participant_anonymized",
MANAGE_CONSENT_FORMS: "manage_consent_forms",
// Robot permissions
// Experiment Design
CREATE_EXPERIMENT: "create_experiment",
EDIT_EXPERIMENT: "edit_experiment",
DELETE_EXPERIMENT: "delete_experiment",
DEFINE_ROBOT_BEHAVIORS: "define_robot_behaviors",
CONFIGURE_DATA_COLLECTION: "configure_data_collection",
// Experiment Execution
RUN_TRIALS: "run_trials",
CONTROL_ROBOT: "control_robot",
VIEW_ROBOT_STATUS: "view_robot_status",
MONITOR_SESSIONS: "monitor_sessions",
ADD_REALTIME_ANNOTATIONS: "add_realtime_annotations",
// Experiment permissions
RECORD_EXPERIMENT: "record_experiment",
VIEW_EXPERIMENT: "view_experiment",
VIEW_EXPERIMENT_DATA: "view_experiment_data",
EXPORT_EXPERIMENT_DATA: "export_experiment_data",
ANNOTATE_EXPERIMENT: "annotate_experiment",
// Data Access
VIEW_RAW_DATA: "view_raw_data",
VIEW_ANONYMIZED_DATA: "view_anonymized_data",
EXPORT_DATA: "export_data",
// Administrative permissions
MANAGE_ROLES: "manage_roles",
MANAGE_USERS: "manage_users",
MANAGE_SYSTEM_SETTINGS: "manage_system_settings",
// User Management
INVITE_USERS: "invite_users",
ASSIGN_ROLES: "assign_roles",
REMOVE_USERS: "remove_users",
MANAGE_PERMISSIONS: "manage_permissions",
} as const;
export type Permission = keyof typeof PERMISSIONS;
export type PermissionValue = (typeof PERMISSIONS)[Permission];
export const ROLES = {
OWNER: "owner",
ADMIN: "admin",
PRINCIPAL_INVESTIGATOR: "principal_investigator",
RESEARCHER: "researcher",
WIZARD: "wizard",
RESEARCHER: "researcher",
OBSERVER: "observer",
ASSISTANT: "assistant",
} as const;
export type Role = keyof typeof ROLES;
export type RoleValue = (typeof ROLES)[Role];
export const ROLE_PERMISSIONS: Record<Role, Permission[]> = {
ADMIN: Object.keys(PERMISSIONS) as Permission[],
OWNER: Object.keys(PERMISSIONS) as Permission[],
PRINCIPAL_INVESTIGATOR: [
"CREATE_STUDY",
ADMIN: [
"EDIT_STUDY",
"DELETE_STUDY",
"VIEW_STUDY",
"VIEW_PARTICIPANT_NAMES",
"CREATE_PARTICIPANT",
"MANAGE_STUDY_METADATA",
"ADD_PARTICIPANT",
"EDIT_PARTICIPANT",
"DELETE_PARTICIPANT",
"VIEW_ROBOT_STATUS",
"VIEW_EXPERIMENT",
"VIEW_EXPERIMENT_DATA",
"EXPORT_EXPERIMENT_DATA",
"ANNOTATE_EXPERIMENT",
"MANAGE_ROLES",
"MANAGE_USERS",
"VIEW_PARTICIPANT_IDENTIFIABLE",
"VIEW_PARTICIPANT_ANONYMIZED",
"MANAGE_CONSENT_FORMS",
"CREATE_EXPERIMENT",
"EDIT_EXPERIMENT",
"DELETE_EXPERIMENT",
"DEFINE_ROBOT_BEHAVIORS",
"CONFIGURE_DATA_COLLECTION",
"RUN_TRIALS",
"CONTROL_ROBOT",
"MONITOR_SESSIONS",
"ADD_REALTIME_ANNOTATIONS",
"VIEW_RAW_DATA",
"VIEW_ANONYMIZED_DATA",
"EXPORT_DATA",
"INVITE_USERS",
"ASSIGN_ROLES",
"REMOVE_USERS",
],
RESEARCHER: [
PRINCIPAL_INVESTIGATOR: [
"VIEW_STUDY",
"VIEW_ROBOT_STATUS",
"VIEW_EXPERIMENT",
"VIEW_EXPERIMENT_DATA",
"EXPORT_EXPERIMENT_DATA",
"ANNOTATE_EXPERIMENT",
"ADD_PARTICIPANT",
"EDIT_PARTICIPANT",
"DELETE_PARTICIPANT",
"VIEW_PARTICIPANT_IDENTIFIABLE",
"VIEW_PARTICIPANT_ANONYMIZED",
"MANAGE_CONSENT_FORMS",
"CREATE_EXPERIMENT",
"EDIT_EXPERIMENT",
"DELETE_EXPERIMENT",
"DEFINE_ROBOT_BEHAVIORS",
"CONFIGURE_DATA_COLLECTION",
"RUN_TRIALS",
"CONTROL_ROBOT",
"MONITOR_SESSIONS",
"ADD_REALTIME_ANNOTATIONS",
"VIEW_RAW_DATA",
"VIEW_ANONYMIZED_DATA",
"EXPORT_DATA",
"INVITE_USERS",
],
WIZARD: [
"VIEW_STUDY",
"VIEW_ROBOT_STATUS",
"VIEW_PARTICIPANT_ANONYMIZED",
"RUN_TRIALS",
"CONTROL_ROBOT",
"RECORD_EXPERIMENT",
"VIEW_EXPERIMENT",
"ANNOTATE_EXPERIMENT",
"MONITOR_SESSIONS",
"ADD_REALTIME_ANNOTATIONS",
"VIEW_ANONYMIZED_DATA",
],
RESEARCHER: [
"VIEW_STUDY",
"VIEW_PARTICIPANT_ANONYMIZED",
"MONITOR_SESSIONS",
"ADD_REALTIME_ANNOTATIONS",
"VIEW_ANONYMIZED_DATA",
"EXPORT_DATA",
],
OBSERVER: [
"VIEW_STUDY",
"VIEW_ROBOT_STATUS",
"VIEW_EXPERIMENT",
"VIEW_EXPERIMENT_DATA",
"ANNOTATE_EXPERIMENT",
],
ASSISTANT: [
"VIEW_STUDY",
"VIEW_ROBOT_STATUS",
"VIEW_EXPERIMENT",
"VIEW_PARTICIPANT_ANONYMIZED",
"MONITOR_SESSIONS",
"ADD_REALTIME_ANNOTATIONS",
],
};
export const ROLE_DESCRIPTIONS: Record<Role, string> = {
ADMIN: "Full system administrator with all permissions",
PRINCIPAL_INVESTIGATOR: "Lead researcher responsible for study design and oversight",
RESEARCHER: "Study team member with access to anonymized data and experiment monitoring capabilities",
WIZARD: "Operator controlling robot behavior during experiments",
OBSERVER: "Team member observing and annotating experiments",
ASSISTANT: "Support staff with limited view access",
OWNER: "Study owner with full control and exclusive ability to delete study or transfer ownership",
ADMIN: "Administrator with ability to manage participants, experiments, and other members",
PRINCIPAL_INVESTIGATOR: "Scientific lead with full access to participant data and experiment design",
WIZARD: "Operator controlling robot behavior during experiment trials",
RESEARCHER: "Team member who can analyze experiment data and results",
OBSERVER: "Team member who can view experiments and add annotations",
};
export const PERMISSION_DESCRIPTIONS: Record<Permission, string> = {
// Study Management
CREATE_STUDY: "Create new research studies",
EDIT_STUDY: "Modify existing study parameters",
DELETE_STUDY: "Remove studies from the system",
TRANSFER_OWNERSHIP: "Transfer study ownership to another user",
VIEW_STUDY: "View study details and progress",
VIEW_PARTICIPANT_NAMES: "Access participant identifying information",
CREATE_PARTICIPANT: "Add new participants to studies",
MANAGE_STUDY_METADATA: "Manage study metadata and settings",
// Participant Management
ADD_PARTICIPANT: "Add new participants to studies",
EDIT_PARTICIPANT: "Update participant information",
DELETE_PARTICIPANT: "Remove participants from studies",
CONTROL_ROBOT: "Operate robot during experiments",
VIEW_ROBOT_STATUS: "Monitor robot state and sensors",
RECORD_EXPERIMENT: "Start/stop experiment recording",
VIEW_EXPERIMENT: "View experiment progress and details",
VIEW_EXPERIMENT_DATA: "Access collected experiment data",
EXPORT_EXPERIMENT_DATA: "Download experiment data",
ANNOTATE_EXPERIMENT: "Add notes and annotations to experiments",
MANAGE_ROLES: "Assign and modify user roles",
MANAGE_USERS: "Add and remove system users",
MANAGE_SYSTEM_SETTINGS: "Configure system-wide settings",
VIEW_PARTICIPANT_IDENTIFIABLE: "Access participant identifying information",
VIEW_PARTICIPANT_ANONYMIZED: "View anonymized participant data",
MANAGE_CONSENT_FORMS: "Manage participant consent forms",
// Experiment Design
CREATE_EXPERIMENT: "Create new experiments",
EDIT_EXPERIMENT: "Modify existing experiments",
DELETE_EXPERIMENT: "Remove experiments from studies",
DEFINE_ROBOT_BEHAVIORS: "Define and configure robot behaviors",
CONFIGURE_DATA_COLLECTION: "Configure experiment data collection",
// Experiment Execution
RUN_TRIALS: "Execute experiment trials",
CONTROL_ROBOT: "Control robot during experiments",
MONITOR_SESSIONS: "Monitor live experiment sessions",
ADD_REALTIME_ANNOTATIONS: "Add annotations during experiments",
// Data Access
VIEW_RAW_DATA: "Access raw experiment data",
VIEW_ANONYMIZED_DATA: "Access anonymized experiment data",
EXPORT_DATA: "Export experiment data",
// User Management
INVITE_USERS: "Invite new users to the study",
ASSIGN_ROLES: "Assign roles to study members",
REMOVE_USERS: "Remove users from the study",
MANAGE_PERMISSIONS: "Manage user permissions",
};

View File

@@ -9,6 +9,11 @@ import {
import { type Permission, type PermissionValue } from "./constants";
import { auth } from "~/server/auth";
import { TRPCError } from "@trpc/server";
import { getServerSession } from "next-auth";
import { studyMembers } from "~/server/db/schema";
import { ROLE_PERMISSIONS, ROLES, PERMISSIONS } from "./constants";
import type { Session } from "next-auth";
import { studies } from "~/server/db/schema";
export async function getUserPermissions(userId: string, studyId?: number) {
const conditions = [eq(userRoles.userId, userId)];
@@ -110,42 +115,62 @@ export async function getUserStudyRoles(userId: string, studyId: number) {
);
}
interface PermissionCheck {
studyId: number;
permission?: PermissionValue;
requireStudyAccess?: boolean;
interface CheckPermissionsOptions {
studyId?: number;
permission: Permission;
session: Session | null;
}
export async function checkPermissions(check: PermissionCheck) {
const session = await auth();
if (!session?.user?.id) {
export async function checkPermissions({
studyId,
permission,
session,
}: CheckPermissionsOptions): Promise<void> {
if (!session?.user) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You must be logged in to perform this action",
});
}
const { studyId, permission, requireStudyAccess = true } = check;
if (requireStudyAccess) {
const hasAccess = await hasStudyAccess(session.user.id, studyId);
if (!hasAccess) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
// Anyone who is logged in can create a study
if (!studyId) {
if (permission === "CREATE_STUDY") {
return;
}
throw new TRPCError({
code: "BAD_REQUEST",
message: "Study ID is required for this action",
});
}
if (permission) {
const hasRequiredPermission = await hasPermission(session.user.id, permission, studyId);
if (!hasRequiredPermission) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to perform this action",
});
}
const membership = await db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
return { userId: session.user.id };
// Normalize role (convert membership.role to uppercase) so that it matches the keys in ROLE_PERMISSIONS
const normalizedRole = membership.role.toUpperCase() as keyof typeof ROLE_PERMISSIONS;
const permittedActions = ROLE_PERMISSIONS[normalizedRole] ?? [];
// For owners, they have all permissions
if (normalizedRole === "OWNER") {
return;
}
if (!permittedActions.includes(permission)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to perform this action",
});
}
}

View File

@@ -1,131 +1,246 @@
import { z } from "zod"
import { eq } from "drizzle-orm"
import { and, eq, count } from "drizzle-orm"
import { TRPCError } from "@trpc/server"
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"
import { participants } from "~/server/db/schema"
import { participants, studyMembers, type ParticipantStatus } from "~/server/db/schema"
import { ROLES, PERMISSIONS } from "~/lib/permissions/constants"
import { checkPermissions } from "~/lib/permissions/server"
import { PERMISSIONS } from "~/lib/permissions/constants"
import { studyActivities } from "~/server/db/schema/studies"
const createParticipantSchema = z.object({
studyId: z.string().uuid(),
identifier: z.string().min(1).max(256),
email: z.string().email().optional(),
firstName: z.string().max(256).optional(),
lastName: z.string().max(256).optional(),
studyId: z.number(),
identifier: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
firstName: z.string().optional(),
lastName: z.string().optional(),
notes: z.string().optional(),
status: z.enum(["active", "inactive"]).default("active"),
status: z.enum(["active", "inactive", "completed", "withdrawn"]).default("active"),
})
const updateParticipantSchema = z.object({
id: z.number(),
identifier: z.string().optional(),
email: z.string().email().optional().or(z.literal("")),
firstName: z.string().optional(),
lastName: z.string().optional(),
notes: z.string().optional(),
status: z.enum(["active", "inactive", "completed", "withdrawn"]).optional(),
})
export const participantRouter = createTRPCRouter({
getAll: protectedProcedure
.input(z.object({ studyId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
await checkPermissions({
studyId: input.studyId,
permission: PERMISSIONS.VIEW_PARTICIPANTS,
})
return ctx.db.query.participants.findMany({
where: eq(participants.studyId, input.studyId),
orderBy: (participants, { asc }) => [asc(participants.identifier)],
})
}),
getById: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const participant = await ctx.db.query.participants.findFirst({
where: eq(participants.id, input.id),
})
});
if (!participant) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Participant not found",
})
});
}
// Check if user has permission to view participants
await checkPermissions({
studyId: participant.studyId,
permission: PERMISSIONS.VIEW_PARTICIPANTS,
})
permission: "VIEW_PARTICIPANT_ANONYMIZED",
session: ctx.session,
});
return participant
// Check if user has permission to view identifiable information
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, participant.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
const canViewIdentifiable = membership && [
ROLES.OWNER.toLowerCase(),
ROLES.ADMIN.toLowerCase(),
ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()
].includes(membership.role.toLowerCase());
if (!canViewIdentifiable) {
return {
...participant,
identifier: participant.identifier ? "REDACTED" : null,
email: participant.email ? "REDACTED" : null,
firstName: participant.firstName ? "REDACTED" : null,
lastName: participant.lastName ? "REDACTED" : null,
};
}
return participant;
}),
getByStudyId: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user has permission to view participants
await checkPermissions({
studyId: input.studyId,
permission: "VIEW_PARTICIPANT_ANONYMIZED",
session: ctx.session,
});
// Get participants
const studyParticipants = await ctx.db.query.participants.findMany({
where: eq(participants.studyId, input.studyId),
orderBy: participants.createdAt,
});
// Check if user has permission to view identifiable information
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
const canViewIdentifiable = membership && [
ROLES.OWNER.toLowerCase(),
ROLES.ADMIN.toLowerCase(),
ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()
].includes(membership.role.toLowerCase());
if (!canViewIdentifiable) {
return studyParticipants.map(participant => ({
...participant,
identifier: participant.identifier ? "REDACTED" : null,
email: participant.email ? "REDACTED" : null,
firstName: participant.firstName ? "REDACTED" : null,
lastName: participant.lastName ? "REDACTED" : null,
}));
}
return studyParticipants;
}),
create: protectedProcedure
.input(createParticipantSchema)
.mutation(async ({ ctx, input }) => {
// Check if user has permission to add participants
await checkPermissions({
studyId: input.studyId,
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
})
permission: "ADD_PARTICIPANT",
session: ctx.session,
});
const [participant] = await ctx.db
.insert(participants)
.values(input)
.returning()
.values({
...input,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return participant
// Log activity
await ctx.db.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "participant_added",
description: `Added participant ${input.identifier ?? 'without identifier'}`,
});
return participant;
}),
update: protectedProcedure
.input(z.object({
id: z.string().uuid(),
...createParticipantSchema.partial().shape,
}))
.input(updateParticipantSchema)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input
// First get the participant to check study membership
const participant = await ctx.db.query.participants.findFirst({
where: eq(participants.id, id),
})
where: eq(participants.id, input.id),
});
if (!participant) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Participant not found",
})
});
}
// Check if user has permission to edit participants
await checkPermissions({
studyId: participant.studyId,
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
})
permission: "EDIT_PARTICIPANT",
session: ctx.session,
});
const [updated] = await ctx.db
const [updatedParticipant] = await ctx.db
.update(participants)
.set({
...data,
...input,
updatedAt: new Date(),
})
.where(eq(participants.id, id))
.returning()
.where(eq(participants.id, input.id))
.returning();
return updated
// Log activity
await ctx.db.insert(studyActivities).values({
studyId: participant.studyId,
userId: ctx.session.user.id,
type: "participant_updated",
description: `Updated participant ${participant.identifier ?? 'without identifier'}`,
});
return updatedParticipant;
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
// First get the participant to check study membership
const participant = await ctx.db.query.participants.findFirst({
where: eq(participants.id, input.id),
})
});
if (!participant) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Participant not found",
})
});
}
// Check if user has permission to delete participants
await checkPermissions({
studyId: participant.studyId,
permission: PERMISSIONS.MANAGE_PARTICIPANTS,
})
permission: "DELETE_PARTICIPANT",
session: ctx.session,
});
await ctx.db.delete(participants).where(eq(participants.id, input.id))
// Log activity before deletion
await ctx.db.insert(studyActivities).values({
studyId: participant.studyId,
userId: ctx.session.user.id,
type: "participant_removed",
description: `Removed participant ${participant.identifier ?? 'without identifier'}`,
});
return { success: true }
await ctx.db.delete(participants).where(eq(participants.id, input.id));
return { success: true };
}),
})
getCount: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user has permission to view participants
await checkPermissions({
studyId: input.studyId,
permission: "VIEW_PARTICIPANT_ANONYMIZED",
session: ctx.session,
});
const [result] = await ctx.db
.select({ count: count() })
.from(participants)
.where(eq(participants.studyId, input.studyId));
return result.count;
}),
});

View File

@@ -1,9 +1,29 @@
import { z } from "zod"
import { eq } from "drizzle-orm"
import { and, eq, desc } from "drizzle-orm"
import { TRPCError } from "@trpc/server"
import { randomBytes } from "crypto"
import { addDays } from "date-fns"
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc"
import { studies, studyMembers } from "~/server/db/schema"
import { createTRPCRouter, protectedProcedure, publicProcedure } from "~/server/api/trpc"
import { studies, studyMembers, studyMetadata, studyActivities, studyInvitations } from "~/server/db/schema/studies"
import { users } from "~/server/db/schema/auth"
import { checkPermissions } from "~/lib/permissions/server"
import { type Permission } from "~/lib/permissions/constants"
import { db } from '~/server/db'
import { ROLES } from "~/lib/permissions/constants"
import { EmailService } from "~/server/email/service"
import { PERMISSIONS } from "~/lib/permissions/constants"
const createStudySchema = z.object({
title: z.string().min(1).max(255),
description: z.string().optional(),
})
const updateStudySchema = z.object({
id: z.number(),
title: z.string().min(1).max(255),
description: z.string().optional(),
})
export const studyRouter = createTRPCRouter({
getMyStudies: protectedProcedure.query(async ({ ctx }) => {
@@ -15,14 +35,10 @@ export const studyRouter = createTRPCRouter({
role: studyMembers.role,
})
.from(studies)
.innerJoin(
studyMembers,
eq(studies.id, studyMembers.studyId),
)
.innerJoin(studyMembers, eq(studyMembers.studyId, studies.id))
.where(eq(studyMembers.userId, ctx.session.user.id))
.orderBy(studies.createdAt)
return myStudies
.orderBy(studies.createdAt);
return myStudies;
}),
getById: protectedProcedure
@@ -36,34 +52,23 @@ export const studyRouter = createTRPCRouter({
role: studyMembers.role,
})
.from(studies)
.innerJoin(
studyMembers,
eq(studies.id, studyMembers.studyId),
)
.where(
eq(studies.id, input.id),
eq(studyMembers.userId, ctx.session.user.id)
)
.innerJoin(studyMembers, eq(studyMembers.studyId, studies.id))
.where(and(eq(studies.id, input.id), eq(studyMembers.userId, ctx.session.user.id)))
.limit(1)
.then((rows) => rows[0])
.then((rows) => rows[0]);
if (!study) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
})
});
}
return study
return study;
}),
create: protectedProcedure
.input(
z.object({
title: z.string().min(1).max(256),
description: z.string().optional(),
}),
)
.input(createStudySchema)
.mutation(async ({ ctx, input }) => {
const study = await ctx.db.transaction(async (tx) => {
const [newStudy] = await tx
@@ -73,125 +78,808 @@ export const studyRouter = createTRPCRouter({
description: input.description,
createdById: ctx.session.user.id,
})
.returning()
.returning();
if (!newStudy) {
throw new Error("Failed to create study")
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create study",
});
}
// Assign creator as owner
await tx.insert(studyMembers).values({
studyId: newStudy.id,
userId: ctx.session.user.id,
role: "admin",
})
role: ROLES.OWNER,
});
return newStudy
})
// Log activity
await tx.insert(studyActivities).values({
studyId: newStudy.id,
userId: ctx.session.user.id,
type: "study_created",
description: "Created study and assigned as owner",
});
return study
return newStudy;
});
return study;
}),
update: protectedProcedure
.input(
z.object({
id: z.number(),
title: z.string().min(1).max(256),
description: z.string().optional(),
}),
)
.input(updateStudySchema)
.mutation(async ({ ctx, input }) => {
// Check if user has access to study
const member = await ctx.db
.select({ role: studyMembers.role })
.from(studyMembers)
.where(
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.id),
eq(studyMembers.userId, ctx.session.user.id)
)
.limit(1)
.then((rows) => rows[0])
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!member) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
})
}
if (member.role !== "admin") {
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to update this study",
})
message: "You do not have permission to edit this study",
});
}
const [study] = await ctx.db
const [updatedStudy] = await ctx.db
.update(studies)
.set({
title: input.title,
description: input.description,
updatedAt: new Date(),
})
.where(eq(studies.id, input.id))
.returning()
.returning();
if (!study) {
if (!updatedStudy) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
})
});
}
return study
return updatedStudy;
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
// Check if user has access to study
const member = await ctx.db
.select({ role: studyMembers.role })
.from(studyMembers)
.where(
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.id),
eq(studyMembers.userId, ctx.session.user.id)
)
.limit(1)
.then((rows) => rows[0])
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!member) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
})
}
if (member.role !== "admin") {
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to delete this study",
})
});
}
await ctx.db.transaction(async (tx) => {
// Delete study members first (foreign key constraint)
await tx
.delete(studyMembers)
.where(eq(studyMembers.studyId, input.id))
// Then delete the study
const [study] = await tx
await tx.delete(studyMembers).where(eq(studyMembers.studyId, input.id));
const [deletedStudy] = await tx
.delete(studies)
.where(eq(studies.id, input.id))
.returning()
.returning();
if (!study) {
if (!deletedStudy) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
})
});
}
})
});
return { success: true }
return { success: true };
}),
})
createMutation: protectedProcedure
.input(
z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
if (!ctx.session?.user?.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "User must be authenticated to create a study",
});
}
try {
const result = await db
.insert(studies)
.values({
title: input.title,
description: input.description ?? "",
createdById: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return result[0];
} catch (error) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create study",
cause: error,
});
}
}),
getMembers: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user is a member of the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view study members",
});
}
const members = await ctx.db
.select({
userId: studyMembers.userId,
role: studyMembers.role,
email: users.email,
firstName: users.firstName,
lastName: users.lastName,
})
.from(studyMembers)
.innerJoin(users, eq(users.id, studyMembers.userId))
.where(eq(studyMembers.studyId, input.studyId));
return members.map(member => ({
...member,
name: member.firstName && member.lastName
? `${member.firstName} ${member.lastName}`
: "Unknown",
}));
}),
getPendingInvitations: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user is a member of the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view study invitations",
});
}
const invitations = await ctx.db
.select({
id: studyInvitations.id,
email: studyInvitations.email,
role: studyInvitations.role,
createdAt: studyInvitations.createdAt,
expiresAt: studyInvitations.expiresAt,
creatorName: users.firstName,
})
.from(studyInvitations)
.innerJoin(users, eq(users.id, studyInvitations.createdById))
.where(and(
eq(studyInvitations.studyId, input.studyId),
eq(studyInvitations.status, "pending"),
))
.orderBy(desc(studyInvitations.createdAt));
return invitations;
}),
revokeInvitation: protectedProcedure
.input(z.object({
studyId: z.number(),
invitationId: z.number(),
}))
.mutation(async ({ ctx, input }) => {
// Check if user is an admin
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only admins can revoke invitations",
});
}
// Update invitation status
await ctx.db.transaction(async (tx) => {
const [invitation] = await tx
.update(studyInvitations)
.set({
status: "revoked",
updatedAt: new Date(),
})
.where(and(
eq(studyInvitations.id, input.invitationId),
eq(studyInvitations.studyId, input.studyId),
eq(studyInvitations.status, "pending"),
))
.returning();
if (!invitation) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invitation not found or already used",
});
}
// Log activity
await tx.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "invitation_revoked",
description: `Revoked invitation for ${invitation.email}`,
});
});
return { success: true };
}),
inviteMember: protectedProcedure
.input(z.object({
studyId: z.number(),
email: z.string().email(),
role: z.enum(["researcher", "observer", "wizard", "principal_investigator", "admin"] as const),
}))
.mutation(async ({ ctx, input }) => {
// Check if user has permission to invite members
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You must be a member to invite others",
});
}
// Define role hierarchy
const roleHierarchy = {
[ROLES.OWNER.toLowerCase()]: [ROLES.ADMIN, ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
[ROLES.ADMIN.toLowerCase()]: [ROLES.PRINCIPAL_INVESTIGATOR, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
[ROLES.PRINCIPAL_INVESTIGATOR.toLowerCase()]: [ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD].map(r => r.toLowerCase()),
};
const userRole = membership.role.toLowerCase();
const targetRole = input.role.toLowerCase();
// Check if user can invite with the specified role
const allowedRoles = roleHierarchy[userRole] ?? [];
if (!allowedRoles.includes(targetRole)) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You cannot invite members with this role",
});
}
// Get study details for the email
const study = await ctx.db.query.studies.findFirst({
where: eq(studies.id, input.studyId),
});
if (!study) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
}
// Check if there's already a pending invitation
const existingInvitation = await ctx.db.query.studyInvitations.findFirst({
where: and(
eq(studyInvitations.studyId, input.studyId),
eq(studyInvitations.email, input.email),
eq(studyInvitations.status, "pending"),
),
});
if (existingInvitation) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "An invitation has already been sent to this email",
});
}
// Check if the user is already a member (if they exist)
const existingUser = await ctx.db.query.users.findFirst({
where: eq(users.email, input.email),
});
if (existingUser) {
const existingMembership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, existingUser.id),
),
});
if (existingMembership) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "User is already a member of this study",
});
}
}
// Generate a secure random token
const token = randomBytes(32).toString("hex");
const expiresAt = addDays(new Date(), 7); // 7 days from now
// Create the invitation
const [invitation] = await ctx.db
.insert(studyInvitations)
.values({
studyId: input.studyId,
email: input.email,
role: input.role,
token,
expiresAt,
createdById: ctx.session.user.id,
})
.returning();
// Log invitation sent activity
await ctx.db.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "invitation_sent",
description: `Sent invitation to ${input.email} for role ${input.role}`,
});
// Send the invitation email
const inviteUrl = `${process.env.NEXTAUTH_URL}/invite?token=${token}`;
const emailService = new EmailService();
await emailService.sendStudyInvitation({
to: input.email,
studyTitle: study.title,
role: input.role,
inviteUrl,
});
return invitation;
}),
updateMemberRole: protectedProcedure
.input(z.object({
studyId: z.number(),
userId: z.string(),
role: z.enum([ROLES.ADMIN, ROLES.RESEARCHER, ROLES.OBSERVER, ROLES.WIZARD]),
}))
.mutation(async ({ ctx, input }) => {
// Check if user is an admin
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only admins can update member roles",
});
}
// Get user details for activity log
const user = await ctx.db.query.users.findFirst({
where: eq(users.id, input.userId),
});
if (!user) {
throw new TRPCError({
code: "NOT_FOUND",
message: "User not found",
});
}
// Update role and log activity
await ctx.db.transaction(async (tx) => {
await tx
.update(studyMembers)
.set({ role: input.role })
.where(
and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, input.userId),
),
);
await tx.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "member_role_changed",
description: `Updated ${user.firstName} ${user.lastName}'s role to ${input.role}`,
});
});
return { success: true };
}),
getMetadata: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user is a member of the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view study metadata",
});
}
return ctx.db.query.studyMetadata.findMany({
where: eq(studyMetadata.studyId, input.studyId),
orderBy: studyMetadata.key,
});
}),
addMetadata: protectedProcedure
.input(z.object({
studyId: z.number(),
key: z.string().min(1),
value: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Check if user is an admin
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only admins can add metadata",
});
}
// Check if key already exists
const existing = await ctx.db.query.studyMetadata.findFirst({
where: and(
eq(studyMetadata.studyId, input.studyId),
eq(studyMetadata.key, input.key),
),
});
if (existing) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "A field with this name already exists",
});
}
// Add metadata and log activity
await ctx.db.transaction(async (tx) => {
await tx.insert(studyMetadata).values({
studyId: input.studyId,
key: input.key,
value: input.value,
});
await tx.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "study_updated",
description: `Added metadata field: ${input.key}`,
});
});
return { success: true };
}),
deleteMetadata: protectedProcedure
.input(z.object({
studyId: z.number(),
key: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Check if user is an admin
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || membership.role !== ROLES.ADMIN) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only admins can delete metadata",
});
}
// Delete metadata and log activity
await ctx.db.transaction(async (tx) => {
await tx
.delete(studyMetadata)
.where(
and(
eq(studyMetadata.studyId, input.studyId),
eq(studyMetadata.key, input.key),
),
);
await tx.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "study_updated",
description: `Deleted metadata field: ${input.key}`,
});
});
return { success: true };
}),
getActivities: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user is a member of the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view study activities",
});
}
const activities = await ctx.db
.select({
id: studyActivities.id,
type: studyActivities.type,
description: studyActivities.description,
userId: studyActivities.userId,
userName: users.firstName,
createdAt: studyActivities.createdAt,
})
.from(studyActivities)
.innerJoin(users, eq(users.id, studyActivities.userId))
.where(eq(studyActivities.studyId, input.studyId))
.orderBy(desc(studyActivities.createdAt))
.limit(50);
return activities;
}),
getInvitation: publicProcedure
.input(z.object({ token: z.string() }))
.query(async ({ ctx, input }) => {
const invitation = await db.query.studyInvitations.findFirst({
where: eq(studyInvitations.token, input.token),
with: {
study: true,
creator: true,
},
});
if (!invitation) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invitation not found",
});
}
if (invitation.status !== "pending") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invitation has already been used",
});
}
if (new Date() > invitation.expiresAt) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invitation has expired",
});
}
return invitation;
}),
acceptInvitation: protectedProcedure
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const invitation = await ctx.db.query.studyInvitations.findFirst({
where: eq(studyInvitations.token, input.token),
});
if (!invitation) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Invitation not found",
});
}
if (invitation.status !== "pending") {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invitation has already been used",
});
}
if (new Date() > invitation.expiresAt) {
// Log expired invitation
await ctx.db.transaction(async (tx) => {
await tx
.update(studyInvitations)
.set({
status: "expired",
updatedAt: new Date(),
})
.where(eq(studyInvitations.id, invitation.id));
await tx.insert(studyActivities).values({
studyId: invitation.studyId,
userId: ctx.session.user.id,
type: "invitation_expired",
description: `Invitation for ${invitation.email} expired`,
});
});
throw new TRPCError({
code: "BAD_REQUEST",
message: "Invitation has expired",
});
}
if (ctx.session.user.email !== invitation.email) {
throw new TRPCError({
code: "FORBIDDEN",
message: "This invitation was sent to a different email address",
});
}
// Add the user to the study with the specified role
await ctx.db.transaction(async (tx) => {
// Update invitation status
await tx
.update(studyInvitations)
.set({
status: "accepted",
updatedAt: new Date(),
})
.where(eq(studyInvitations.id, invitation.id));
// Add study membership
await tx.insert(studyMembers).values({
studyId: invitation.studyId,
userId: ctx.session.user.id,
role: invitation.role,
});
// Log invitation accepted activity
await tx.insert(studyActivities).values({
studyId: invitation.studyId,
userId: ctx.session.user.id,
type: "invitation_accepted",
description: `Accepted invitation and joined study as ${invitation.role}`,
});
});
return { success: true };
}),
transferOwnership: protectedProcedure
.input(z.object({
studyId: z.number(),
newOwnerId: z.string(),
}))
.mutation(async ({ ctx, input }) => {
// Check if user is the owner
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || membership.role.toLowerCase() !== ROLES.OWNER.toLowerCase()) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Only the owner can transfer ownership",
});
}
// Check if new owner exists and is a member
const newOwnerMembership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, input.newOwnerId),
),
});
if (!newOwnerMembership) {
throw new TRPCError({
code: "NOT_FOUND",
message: "New owner must be a member of the study",
});
}
// Transfer ownership in a transaction
await ctx.db.transaction(async (tx) => {
// Change current owner to admin
await tx
.update(studyMembers)
.set({ role: ROLES.ADMIN })
.where(
and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
);
// Set new owner
await tx
.update(studyMembers)
.set({ role: ROLES.OWNER })
.where(
and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, input.newOwnerId),
),
);
// Log activity
await tx.insert(studyActivities).values({
studyId: input.studyId,
userId: ctx.session.user.id,
type: "ownership_transferred",
description: `Transferred study ownership to ${newOwnerMembership.userId}`,
});
});
return { success: true };
}),
// Additional endpoints (like getOverview or getAll) can be added if needed.
});

View File

@@ -16,6 +16,8 @@ declare module "next-auth" {
user: {
id: string;
email: string;
firstName?: string | null;
lastName?: string | null;
name?: string | null;
image?: string | null;
};
@@ -59,7 +61,8 @@ export const authOptions: NextAuthOptions = {
return {
id: user.id,
email: user.email,
name: user.name ?? null,
firstName: user.firstName,
lastName: user.lastName,
image: user.image ?? null,
};
},

View File

@@ -26,13 +26,20 @@ declare module "@auth/core/types" {
user: {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
} & DefaultSession["user"];
}
// interface User {
// // ...other properties
// // role: UserRole;
// }
interface User {
id?: string;
email?: string | null;
firstName?: string | null;
lastName?: string | null;
password?: string | null;
emailVerified?: Date | null;
image?: string | null;
}
}
/**
@@ -42,7 +49,7 @@ declare module "@auth/core/types" {
*/
export const authConfig = {
adapter: DrizzleAdapter(db, {
usersTable: users,
usersTable: users as any,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
@@ -89,7 +96,10 @@ export const authConfig = {
return {
id: user.id,
email: user.email,
name: user.name ?? null,
firstName: user.firstName,
lastName: user.lastName,
emailVerified: user.emailVerified,
image: user.image,
};
}
})
@@ -100,10 +110,14 @@ export const authConfig = {
user: {
...session.user,
id: user.id,
email: user.email,
name: user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : null,
firstName: user.firstName,
lastName: user.lastName,
},
}),
},
pages: {
signIn: '/login',
signIn: '/auth/signin',
},
} satisfies AuthConfig;

View File

@@ -1,216 +1,4 @@
import { relations, sql } from "drizzle-orm";
import {
index,
integer,
pgTableCreator,
primaryKey,
text,
timestamp,
varchar,
} from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters";
/**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects.
*
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = pgTableCreator((name) => `hristudio_${name}`);
export const posts = createTable(
"post",
{
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
name: varchar("name", { length: 256 }),
createdById: varchar("created_by", { length: 255 })
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
() => new Date()
),
},
(example) => ({
createdByIdIdx: index("created_by_idx").on(example.createdById),
nameIndex: index("name_idx").on(example.name),
})
);
export const users = createTable("user", {
id: varchar("id", { length: 255 })
.notNull()
.primaryKey()
.$defaultFn(() => crypto.randomUUID()),
firstName: varchar("first_name", { length: 255 }),
lastName: varchar("last_name", { length: 255 }),
email: varchar("email", { length: 255 }).notNull(),
password: varchar("password", { length: 255 }),
emailVerified: timestamp("email_verified", {
mode: "date",
withTimezone: true,
}).default(sql`CURRENT_TIMESTAMP`),
image: varchar("image", { length: 255 }),
});
export const studies = createTable(
"study",
{
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
createdById: varchar("created_by", { length: 255 })
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
() => new Date()
),
},
(study) => ({
createdByIdIdx: index("study_created_by_idx").on(study.createdById),
titleIndex: index("study_title_idx").on(study.title),
})
);
export const studyMembers = createTable(
"study_member",
{
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id),
role: varchar("role", { length: 50 }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
},
(member) => ({
studyUserIdx: index("study_member_study_user_idx").on(member.studyId, member.userId),
})
);
export const participants = createTable(
"participant",
{
id: integer("id").primaryKey().generatedByDefaultAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id),
identifier: varchar("identifier", { length: 256 }),
email: varchar("email", { length: 256 }),
firstName: varchar("first_name", { length: 256 }),
lastName: varchar("last_name", { length: 256 }),
notes: text("notes"),
status: varchar("status", { length: 50 }).notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).$onUpdate(
() => new Date()
),
},
(participant) => ({
studyIdIdx: index("participant_study_id_idx").on(participant.studyId),
identifierIdx: index("participant_identifier_idx").on(participant.identifier),
emailIdx: index("participant_email_idx").on(participant.email),
})
);
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
members: many(studyMembers),
participants: many(participants),
}));
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
study: one(studies, { fields: [studyMembers.studyId], references: [studies.id] }),
user: one(users, { fields: [studyMembers.userId], references: [users.id] }),
}));
export const participantsRelations = relations(participants, ({ one }) => ({
study: one(studies, { fields: [participants.studyId], references: [studies.id] }),
}));
export const usersRelations = relations(users, ({ many }) => ({
accounts: many(accounts),
studies: many(studyMembers),
}));
export const accounts = createTable(
"account",
{
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id),
type: varchar("type", { length: 255 })
.$type<AdapterAccount["type"]>()
.notNull(),
provider: varchar("provider", { length: 255 }).notNull(),
providerAccountId: varchar("provider_account_id", {
length: 255,
}).notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: varchar("token_type", { length: 255 }),
scope: varchar("scope", { length: 255 }),
id_token: text("id_token"),
session_state: varchar("session_state", { length: 255 }),
},
(account) => ({
compoundKey: primaryKey({
columns: [account.provider, account.providerAccountId],
}),
userIdIdx: index("account_user_id_idx").on(account.userId),
})
);
export const accountsRelations = relations(accounts, ({ one }) => ({
user: one(users, { fields: [accounts.userId], references: [users.id] }),
}));
export const sessions = createTable(
"session",
{
sessionToken: varchar("session_token", { length: 255 })
.notNull()
.primaryKey(),
userId: varchar("user_id", { length: 255 })
.notNull()
.references(() => users.id),
expires: timestamp("expires", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(session) => ({
userIdIdx: index("session_user_id_idx").on(session.userId),
})
);
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, { fields: [sessions.userId], references: [users.id] }),
}));
export const verificationTokens = createTable(
"verification_token",
{
identifier: varchar("identifier", { length: 255 }).notNull(),
token: varchar("token", { length: 255 }).notNull(),
expires: timestamp("expires", {
mode: "date",
withTimezone: true,
}).notNull(),
},
(vt) => ({
compoundKey: primaryKey({ columns: [vt.identifier, vt.token] }),
})
);
// Re-export all schema definitions from individual schema files
export * from "./schema/auth";
export * from "./schema/studies";
export * from "./schema/permissions";

View File

@@ -1,10 +1,44 @@
import { pgTable, varchar, timestamp } from "drizzle-orm/pg-core";
import { text, timestamp, varchar, integer } from "drizzle-orm/pg-core";
import { createTable } from "../utils";
export const users = pgTable("users", {
id: varchar("id", { length: 255 }).primaryKey(),
email: varchar("email", { length: 255 }).notNull().unique(),
name: varchar("name", { length: 255 }),
image: varchar("image", { length: 255 }),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
export const users = createTable("user", {
id: varchar("id", { length: 255 }).notNull().primaryKey(),
email: varchar("email", { length: 255 }).notNull(),
firstName: varchar("first_name", { length: 255 }),
lastName: varchar("last_name", { length: 255 }),
password: varchar("password", { length: 255 }),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
});
export const accounts = createTable("account", {
userId: varchar("userId", { length: 255 })
.notNull()
.references(() => users.id),
type: varchar("type", { length: 255 })
.$type<"oauth" | "oidc" | "email">()
.notNull(),
provider: varchar("provider", { length: 255 }).notNull(),
providerAccountId: varchar("providerAccountId", { length: 255 }).notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
token_type: varchar("token_type", { length: 255 }),
scope: varchar("scope", { length: 255 }),
id_token: text("id_token"),
session_state: varchar("session_state", { length: 255 }),
});
export const sessions = createTable("session", {
sessionToken: varchar("sessionToken", { length: 255 }).notNull().primaryKey(),
userId: varchar("userId", { length: 255 })
.notNull()
.references(() => users.id),
expires: timestamp("expires", { mode: "date" }).notNull(),
});
export const verificationTokens = createTable("verificationToken", {
identifier: varchar("identifier", { length: 255 }).notNull(),
token: varchar("token", { length: 255 }).notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
});

View File

@@ -0,0 +1,151 @@
import { relations } from "drizzle-orm";
import {
integer,
pgEnum,
pgTable,
text,
timestamp,
varchar,
serial
} from "drizzle-orm/pg-core";
import { participants } from "../schema";
import { users } from "./auth";
import { studies } from "./studies";
// Enums
export const experimentStatusEnum = pgEnum("experiment_status", [
"draft",
"active",
"archived"
]);
export const stepTypeEnum = pgEnum("step_type", [
"instruction",
"robot-action",
"wizard-action"
]);
export const actionTypeEnum = pgEnum("action_type", [
"movement",
"speech",
"wait",
"input"
]);
export const trialStatusEnum = pgEnum("trial_status", [
"pending",
"in-progress",
"completed",
"cancelled"
]);
// Tables
export const experiments = pgTable("experiments", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id")
.notNull()
.references(() => studies.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
version: integer("version").notNull().default(1),
status: varchar("status", { length: 50 })
.notNull()
.default("draft")
.$type<typeof experimentStatusEnum.enumValues[number]>(),
createdById: varchar("created_by", { length: 255 })
.notNull()
.references(() => users.id),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const steps = pgTable("steps", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
experimentId: integer("experiment_id")
.notNull()
.references(() => experiments.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
order: integer("order").notNull(),
type: varchar("type", { length: 50 })
.notNull()
.$type<typeof stepTypeEnum.enumValues[number]>(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const actions = pgTable("actions", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
stepId: integer("step_id")
.notNull()
.references(() => steps.id, { onDelete: "cascade" }),
type: varchar("type", { length: 50 })
.notNull()
.$type<typeof actionTypeEnum.enumValues[number]>(),
parameters: text("parameters"), // JSON string of action parameters
order: integer("order").notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const trials = pgTable("trials", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
experimentId: integer("experiment_id")
.notNull()
.references(() => experiments.id, { onDelete: "cascade" }),
participantId: integer("participant_id")
.notNull()
.references(() => participants.id, { onDelete: "cascade" }),
wizardId: varchar("wizard_id", { length: 255 })
.notNull()
.references(() => users.id),
status: varchar("status", { length: 50 })
.notNull()
.default("pending")
.$type<typeof trialStatusEnum.enumValues[number]>(),
startedAt: timestamp("started_at"),
completedAt: timestamp("completed_at"),
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const trialEvents = pgTable("trial_events", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
trialId: integer("trial_id")
.notNull()
.references(() => trials.id, { onDelete: "cascade" }),
type: varchar("type", { length: 50 }).notNull(),
actionId: integer("action_id").references(() => actions.id),
data: text("data"), // JSON string of event data
timestamp: timestamp("timestamp").defaultNow().notNull(),
});
// Relations
export const experimentsRelations = relations(experiments, ({ one, many }) => ({
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
steps: many(steps),
trials: many(trials),
}));
export const stepsRelations = relations(steps, ({ one, many }) => ({
experiment: one(experiments, { fields: [steps.experimentId], references: [experiments.id] }),
actions: many(actions),
}));
export const actionsRelations = relations(actions, ({ one }) => ({
step: one(steps, { fields: [actions.stepId], references: [steps.id] }),
}));
export const trialsRelations = relations(trials, ({ one, many }) => ({
experiment: one(experiments, { fields: [trials.experimentId], references: [experiments.id] }),
participant: one(participants, { fields: [trials.participantId], references: [participants.id] }),
wizard: one(users, { fields: [trials.wizardId], references: [users.id] }),
events: many(trialEvents),
}));
export const trialEventsRelations = relations(trialEvents, ({ one }) => ({
trial: one(trials, { fields: [trialEvents.trialId], references: [trials.id] }),
action: one(actions, { fields: [trialEvents.actionId], references: [actions.id] }),
}));

View File

@@ -1,3 +1,4 @@
export * from "./auth";
export * from "./studies";
export * from "./permissions";
export * from "./permissions";
export * from "./experiments";

View File

@@ -1,17 +1,19 @@
import { relations } from "drizzle-orm";
import {
integer,
pgTable,
primaryKey,
text,
timestamp,
varchar,
serial,
} from "drizzle-orm/pg-core";
import { users } from "~/server/db/schema/auth";
import { studies } from "~/server/db/schema/studies";
import { users } from "./auth";
import { studies } from "./studies";
export const permissions = pgTable("permissions", {
id: integer("id").notNull().primaryKey(),
import { createTable } from "../utils";
export const permissions = createTable("permissions", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
code: varchar("code", { length: 50 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
description: text("description"),
@@ -19,8 +21,8 @@ export const permissions = pgTable("permissions", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const roles = pgTable("roles", {
id: integer("id").notNull().primaryKey(),
export const roles = createTable("roles", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
code: varchar("code", { length: 50 }).notNull().unique(),
name: varchar("name", { length: 100 }).notNull(),
description: text("description"),
@@ -28,7 +30,7 @@ export const roles = pgTable("roles", {
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
export const rolePermissions = pgTable(
export const rolePermissions = createTable(
"role_permissions",
{
roleId: integer("role_id")
@@ -44,7 +46,7 @@ export const rolePermissions = pgTable(
})
);
export const userRoles = pgTable(
export const userRoles = createTable(
"user_roles",
{
userId: varchar("user_id", { length: 255 })

View File

@@ -1,20 +1,159 @@
import { pgTable, integer, varchar, text, timestamp } from "drizzle-orm/pg-core";
import { createEnum } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { integer, pgEnum, text, timestamp, varchar, serial } from "drizzle-orm/pg-core";
import { ROLES } from "~/lib/permissions/constants";
import { createTable } from "../utils";
import { users } from "./auth";
// Create enum from role values, excluding PRINCIPAL_INVESTIGATOR and ASSISTANT
// which are handled through permissions
export const studyRoleEnum = createEnum("study_role", [
// Create enum from role values
export const studyRoleEnum = pgEnum("study_role", [
ROLES.OWNER,
ROLES.ADMIN,
ROLES.RESEARCHER,
ROLES.PRINCIPAL_INVESTIGATOR,
ROLES.WIZARD,
ROLES.RESEARCHER,
ROLES.OBSERVER,
]);
export const studies = pgTable("studies", {
id: integer("id").primaryKey(),
name: varchar("name", { length: 255 }).notNull(),
// Create enum for participant status
export const participantStatusEnum = pgEnum("participant_status", [
"active",
"inactive",
"completed",
"withdrawn",
]);
// Create enum for activity types
export const activityTypeEnum = pgEnum("activity_type", [
"study_created",
"study_updated",
"study_deleted",
"ownership_transferred",
"member_added",
"member_removed",
"member_role_changed",
"participant_added",
"participant_updated",
"participant_removed",
"experiment_created",
"experiment_updated",
"experiment_deleted",
"trial_started",
"trial_completed",
"trial_cancelled",
"invitation_sent",
"invitation_accepted",
"invitation_declined",
"invitation_expired",
"invitation_revoked",
"consent_form_added",
"consent_form_signed",
"metadata_updated",
"data_exported",
]);
// Create enum for invitation status
export const invitationStatusEnum = pgEnum("invitation_status", [
"pending",
"accepted",
"declined",
"expired",
"revoked",
]);
export const studyActivityTypeEnum = pgEnum("study_activity_type", [
"member_added",
"member_role_changed",
"study_updated",
"participant_added",
"participant_updated",
"invitation_sent",
"invitation_accepted",
"invitation_declined",
"invitation_expired",
"invitation_revoked",
]);
export const studies = createTable("study", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
});
export const studyMembers = createTable("study_member", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }),
role: studyRoleEnum("role").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const studyMetadata = createTable("study_metadata", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
key: varchar("key", { length: 256 }).notNull(),
value: text("value"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
});
export const studyActivities = createTable("study_activity", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id),
type: activityTypeEnum("type").notNull(),
description: text("description").notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const participants = createTable("participant", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
// Identifiable information - only visible to roles with VIEW_PARTICIPANT_NAMES permission
identifier: varchar("identifier", { length: 256 }),
email: varchar("email", { length: 256 }),
firstName: varchar("first_name", { length: 256 }),
lastName: varchar("last_name", { length: 256 }),
// Non-identifiable information - visible to all study members
notes: text("notes"),
status: participantStatusEnum("status").notNull().default("active"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
});
export const studyInvitations = createTable("study_invitation", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
email: varchar("email", { length: 255 }).notNull(),
role: studyRoleEnum("role").notNull(),
token: varchar("token", { length: 255 }).notNull().unique(),
status: invitationStatusEnum("status").notNull().default("pending"),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
});
// Relations
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
members: many(studyMembers),
participants: many(participants),
invitations: many(studyInvitations),
}));
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
study: one(studies, { fields: [studyMembers.studyId], references: [studies.id] }),
user: one(users, { fields: [studyMembers.userId], references: [users.id] }),
}));
export const participantsRelations = relations(participants, ({ one }) => ({
study: one(studies, { fields: [participants.studyId], references: [studies.id] }),
}));
export const studyInvitationsRelations = relations(studyInvitations, ({ one }) => ({
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
}));

7
src/server/db/utils.ts Normal file
View File

@@ -0,0 +1,7 @@
import { pgTableCreator } from "drizzle-orm/pg-core";
/**
* This creates tables with the given prefix to avoid naming conflicts in the database
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
*/
export const createTable = pgTableCreator((name) => `hs_${name}`);

View File

@@ -0,0 +1,42 @@
import { z } from "zod";
import { env } from "~/env";
export const emailConfigSchema = z.object({
smtp: z.object({
host: z.string(),
port: z.number(),
secure: z.boolean().default(false),
auth: z.object({
user: z.string(),
pass: z.string(),
}),
tls: z.object({
rejectUnauthorized: z.boolean().default(true),
}).default({}),
}),
from: z.object({
name: z.string(),
email: z.string().email(),
}),
});
export type EmailConfig = z.infer<typeof emailConfigSchema>;
export const emailConfig = {
smtp: {
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT),
secure: false,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
tls: {
rejectUnauthorized: true,
},
},
from: {
name: env.EMAIL_FROM_NAME,
email: env.EMAIL_FROM_ADDRESS,
},
} satisfies EmailConfig;

View File

@@ -0,0 +1,86 @@
import nodemailer from "nodemailer";
import { type EmailConfig, emailConfig } from "./config";
export class EmailService {
private transporter: nodemailer.Transporter;
private config: EmailConfig;
constructor(config: EmailConfig = emailConfig) {
this.config = config;
this.transporter = nodemailer.createTransport(config.smtp);
}
async sendMail(options: {
to: string;
subject: string;
text?: string;
html?: string;
}) {
const { to, subject, text, html } = options;
await this.transporter.sendMail({
from: `"${this.config.from.name}" <${this.config.from.email}>`,
to,
subject,
text,
html,
});
}
async sendStudyInvitation({
to,
studyTitle,
role,
inviteUrl,
}: {
to: string;
studyTitle: string;
role: string;
inviteUrl: string;
}) {
const subject = `Invitation to join "${studyTitle}" as ${role}`;
const html = `
<div style="font-family: sans-serif; max-width: 600px; margin: 0 auto;">
<h2>You've been invited!</h2>
<p>You've been invited to join the study "${studyTitle}" as a ${role}.</p>
<p style="margin: 24px 0;">
<a href="${inviteUrl}"
style="background: #0091FF; color: white; padding: 12px 24px;
text-decoration: none; border-radius: 6px; display: inline-block;">
Accept Invitation
</a>
</p>
<p style="color: #666; font-size: 14px;">
If you can't click the button above, copy and paste this URL into your browser:<br>
${inviteUrl}
</p>
<p style="color: #666; font-size: 14px;">
This invitation will expire in 7 days.
</p>
</div>
`;
const text = `
You've been invited!
You've been invited to join the study "${studyTitle}" as a ${role}.
To accept the invitation, visit this URL:
${inviteUrl}
This invitation will expire in 7 days.
`.trim();
await this.sendMail({ to, subject, html, text });
}
async verifyConnection() {
try {
await this.transporter.verify();
return true;
} catch (error) {
console.error("Failed to verify email connection:", error);
return false;
}
}
}

View File

@@ -15,7 +15,7 @@ const defaultQueryClientOptions = {
onError: (error: unknown) => {
const err = error as { message?: string };
// Ignore unauthorized errors on public pages
if (err?.message === "UNAUTHORIZED" && typeof window !== "undefined" && window.location.pathname.match(/^\/(login|register|$)/)) {
if (err?.message === "UNAUTHORIZED" && typeof window !== "undefined" && window.location.pathname.match(/^\/(auth\/signin|auth\/signup|$)/)) {
return;
}
},