From 67ad904f62a5c99cab5d7df0035af5b96f83321d Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sun, 22 Mar 2026 17:25:04 -0400 Subject: [PATCH] feat: add role-based permissions and profile page improvements - Add getMyMemberships API endpoint for user role lookup - Add getMemberRole helper for profile page display - Add role-based UI controls to study page (owner/researcher only) - Add canManage checks to experiments, participants, trials pages - Hide management actions for wizard/observer roles Backend already enforces permissions; UI now provides cleaner UX --- src/app/(dashboard)/profile/page.tsx | 67 +++++++++++++++++-- .../studies/[id]/experiments/page.tsx | 16 +++-- src/app/(dashboard)/studies/[id]/page.tsx | 61 ++++++++++------- .../studies/[id]/participants/page.tsx | 16 +++-- .../(dashboard)/studies/[id]/trials/page.tsx | 16 +++-- src/server/api/routers/studies.ts | 16 +++++ 6 files changed, 143 insertions(+), 49 deletions(-) diff --git a/src/app/(dashboard)/profile/page.tsx b/src/app/(dashboard)/profile/page.tsx index 97a47ae..b562a4c 100755 --- a/src/app/(dashboard)/profile/page.tsx +++ b/src/app/(dashboard)/profile/page.tsx @@ -19,6 +19,10 @@ import { Loader2, Save, X, + Crown, + FlaskConical, + Eye, + UserCheck, } from "lucide-react"; import { Button } from "~/components/ui/button"; @@ -43,6 +47,17 @@ import { DialogTrigger, } from "~/components/ui/dialog"; +interface Membership { + studyId: string; + role: string; + joinedAt: Date; +} + +function getMemberRole(memberships: Membership[], studyId: string): string { + const membership = memberships.find((m) => m.studyId === studyId); + return membership?.role ?? "observer"; +} + function ProfilePageContent() { const { data: session } = useSession(); const utils = api.useUtils(); @@ -59,6 +74,15 @@ function ProfilePageContent() { { enabled: !!session?.user?.id }, ); + const { data: userStudies } = api.studies.list.useQuery({ + memberOnly: true, + limit: 10, + }); + + const { data: membershipsData } = api.studies.getMyMemberships.useQuery(); + + const studyMemberships = membershipsData ?? []; + const updateProfile = api.users.update.useMutation({ onSuccess: () => { toast.success("Profile updated successfully"); @@ -341,16 +365,45 @@ function ProfilePageContent() { Studies you have access to - -
- -

View your studies

- +
+ )} + {userStudies && userStudies.studies.length > 5 && ( + - + )}
diff --git a/src/app/(dashboard)/studies/[id]/experiments/page.tsx b/src/app/(dashboard)/studies/[id]/experiments/page.tsx index f6faf49..02e6859 100755 --- a/src/app/(dashboard)/studies/[id]/experiments/page.tsx +++ b/src/app/(dashboard)/studies/[id]/experiments/page.tsx @@ -31,6 +31,8 @@ export default function StudyExperimentsPage() { } }, [studyId, selectedStudyId, setSelectedStudyId]); + const canManage = study?.userRole === "owner" || study?.userRole === "researcher"; + return (
- - - Create Experiment - - + canManage ? ( + + ) : null } /> diff --git a/src/app/(dashboard)/studies/[id]/page.tsx b/src/app/(dashboard)/studies/[id]/page.tsx index 452a4f7..922749d 100755 --- a/src/app/(dashboard)/studies/[id]/page.tsx +++ b/src/app/(dashboard)/studies/[id]/page.tsx @@ -60,6 +60,7 @@ type Study = { irbProtocol: string | null; createdAt: Date; updatedAt: Date; + userRole?: string; }; type Member = { @@ -157,6 +158,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { ).length; const totalTrials = trials.length; + const userRole = (studyData as any)?.userRole; + const canManage = userRole === "owner" || userRole === "researcher"; + const canRunTrials = userRole === "owner" || userRole === "researcher" || userRole === "wizard"; + const stats = { experiments: experiments.length, totalTrials: totalTrials, @@ -182,18 +187,22 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { ]} actions={
- - + {canManage && ( + + )} + {canManage && ( + + )}
} /> @@ -235,12 +244,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { icon="FlaskConical" description="Design and manage experimental protocols for this study" actions={ - + canManage ? ( + + ) : null } > {experiments.length === 0 ? ( @@ -392,12 +403,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) { icon="Users" description={`${members.length} team member${members.length !== 1 ? "s" : ""}`} actions={ - - - + canManage ? ( + + + + ) : null } >
diff --git a/src/app/(dashboard)/studies/[id]/participants/page.tsx b/src/app/(dashboard)/studies/[id]/participants/page.tsx index 41b1e7a..4b5c507 100755 --- a/src/app/(dashboard)/studies/[id]/participants/page.tsx +++ b/src/app/(dashboard)/studies/[id]/participants/page.tsx @@ -31,6 +31,8 @@ export default function StudyParticipantsPage() { } }, [studyId, selectedStudyId, setSelectedStudyId]); + const canManage = study?.userRole === "owner" || study?.userRole === "researcher"; + return (
- - - Add Participant - - + canManage ? ( + + ) : null } /> diff --git a/src/app/(dashboard)/studies/[id]/trials/page.tsx b/src/app/(dashboard)/studies/[id]/trials/page.tsx index 3a3b112..7f98900 100755 --- a/src/app/(dashboard)/studies/[id]/trials/page.tsx +++ b/src/app/(dashboard)/studies/[id]/trials/page.tsx @@ -32,6 +32,8 @@ export default function StudyTrialsPage() { } }, [studyId, selectedStudyId, setSelectedStudyId]); + const canRun = ["owner", "researcher", "wizard"].includes(study?.userRole ?? ""); + return (
- - - Schedule Trial - - + canRun ? ( + + ) : null } /> diff --git a/src/server/api/routers/studies.ts b/src/server/api/routers/studies.ts index aa04177..6881c6d 100755 --- a/src/server/api/routers/studies.ts +++ b/src/server/api/routers/studies.ts @@ -1001,4 +1001,20 @@ export const studiesRouter = createTRPCRouter({ return updatedPlugin; }), + + getMyMemberships: protectedProcedure.query(async ({ ctx }) => { + const userId = ctx.session.user.id; + + const memberships = await ctx.db.query.studyMembers.findMany({ + where: eq(studyMembers.userId, userId), + columns: { + studyId: true, + role: true, + joinedAt: true, + }, + orderBy: [desc(studyMembers.joinedAt)], + }); + + return memberships; + }), });