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
This commit is contained in:
2026-03-22 17:25:04 -04:00
parent 519e6a2606
commit 67ad904f62
6 changed files with 143 additions and 49 deletions

View File

@@ -19,6 +19,10 @@ import {
Loader2, Loader2,
Save, Save,
X, X,
Crown,
FlaskConical,
Eye,
UserCheck,
} from "lucide-react"; } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
@@ -43,6 +47,17 @@ import {
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } 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() { function ProfilePageContent() {
const { data: session } = useSession(); const { data: session } = useSession();
const utils = api.useUtils(); const utils = api.useUtils();
@@ -59,6 +74,15 @@ function ProfilePageContent() {
{ enabled: !!session?.user?.id }, { 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({ const updateProfile = api.users.update.useMutation({
onSuccess: () => { onSuccess: () => {
toast.success("Profile updated successfully"); toast.success("Profile updated successfully");
@@ -341,16 +365,45 @@ function ProfilePageContent() {
Studies you have access to Studies you have access to
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-3">
{userStudies?.studies.slice(0, 5).map((study) => (
<Link
key={study.id}
href={`/studies/${study.id}`}
className="hover:bg-accent/50 flex items-center justify-between rounded-md border p-3 transition-colors"
>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10">
<span className="text-xs font-medium text-primary">
{(study.name ?? "S").charAt(0).toUpperCase()}
</span>
</div>
<div>
<p className="text-sm font-medium">{study.name}</p>
<p className="text-muted-foreground text-xs capitalize">
{getMemberRole(studyMemberships, study.id)}
</p>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</Link>
))}
{(!userStudies?.studies.length) && (
<div className="flex flex-col items-center justify-center py-4 text-center"> <div className="flex flex-col items-center justify-center py-4 text-center">
<Building className="text-muted-foreground/50 mb-2 h-8 w-8" /> <Building className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p className="text-sm">View your studies</p> <p className="text-sm">No studies yet</p>
<Button variant="link" size="sm" asChild className="mt-2"> <Button variant="link" size="sm" asChild className="mt-1">
<Link href="/studies"> <Link href="/studies/new">Create a study</Link>
Go to Studies <ChevronRight className="ml-1 h-3 w-3" />
</Link>
</Button> </Button>
</div> </div>
)}
{userStudies && userStudies.studies.length > 5 && (
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/studies">
View all {userStudies.studies.length} studies <ChevronRight className="ml-1 h-3 w-3" />
</Link>
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -31,6 +31,8 @@ export default function StudyExperimentsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canManage = study?.userRole === "owner" || study?.userRole === "researcher";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -38,12 +40,14 @@ export default function StudyExperimentsPage() {
description="Design and manage experiment protocols for this study" description="Design and manage experiment protocols for this study"
icon={FlaskConical} icon={FlaskConical}
actions={ actions={
canManage ? (
<Button asChild> <Button asChild>
<a href={`/studies/${studyId}/experiments/new`}> <a href={`/studies/${studyId}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Create Experiment Create Experiment
</a> </a>
</Button> </Button>
) : null
} }
/> />

View File

@@ -60,6 +60,7 @@ type Study = {
irbProtocol: string | null; irbProtocol: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
userRole?: string;
}; };
type Member = { type Member = {
@@ -157,6 +158,10 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
).length; ).length;
const totalTrials = trials.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 = { const stats = {
experiments: experiments.length, experiments: experiments.length,
totalTrials: totalTrials, totalTrials: totalTrials,
@@ -182,18 +187,22 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
]} ]}
actions={ actions={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canManage && (
<Button asChild variant="outline"> <Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}> <Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" /> <Settings className="mr-2 h-4 w-4" />
Edit Study Edit Study
</Link> </Link>
</Button> </Button>
)}
{canManage && (
<Button asChild> <Button asChild>
<Link href={`/studies/${study.id}/experiments/new`}> <Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
New Experiment New Experiment
</Link> </Link>
</Button> </Button>
)}
</div> </div>
} }
/> />
@@ -235,12 +244,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
icon="FlaskConical" icon="FlaskConical"
description="Design and manage experimental protocols for this study" description="Design and manage experimental protocols for this study"
actions={ actions={
canManage ? (
<Button asChild variant="outline" size="sm"> <Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/new`}> <Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Experiment Add Experiment
</Link> </Link>
</Button> </Button>
) : null
} }
> >
{experiments.length === 0 ? ( {experiments.length === 0 ? (
@@ -392,12 +403,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
icon="Users" icon="Users"
description={`${members.length} team member${members.length !== 1 ? "s" : ""}`} description={`${members.length} team member${members.length !== 1 ? "s" : ""}`}
actions={ actions={
canManage ? (
<AddMemberDialog studyId={study.id}> <AddMemberDialog studyId={study.id}>
<Button variant="outline" size="sm"> <Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Manage Manage
</Button> </Button>
</AddMemberDialog> </AddMemberDialog>
) : null
} }
> >
<div className="space-y-3"> <div className="space-y-3">

View File

@@ -31,6 +31,8 @@ export default function StudyParticipantsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canManage = study?.userRole === "owner" || study?.userRole === "researcher";
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -38,12 +40,14 @@ export default function StudyParticipantsPage() {
description="Manage participant registration, consent, and trial assignments for this study" description="Manage participant registration, consent, and trial assignments for this study"
icon={Users} icon={Users}
actions={ actions={
canManage ? (
<Button asChild> <Button asChild>
<a href={`/studies/${studyId}/participants/new`}> <a href={`/studies/${studyId}/participants/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Add Participant Add Participant
</a> </a>
</Button> </Button>
) : null
} }
/> />

View File

@@ -32,6 +32,8 @@ export default function StudyTrialsPage() {
} }
}, [studyId, selectedStudyId, setSelectedStudyId]); }, [studyId, selectedStudyId, setSelectedStudyId]);
const canRun = ["owner", "researcher", "wizard"].includes(study?.userRole ?? "");
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PageHeader <PageHeader
@@ -39,12 +41,14 @@ export default function StudyTrialsPage() {
description="Manage trial execution, scheduling, and data collection for this study" description="Manage trial execution, scheduling, and data collection for this study"
icon={TestTube} icon={TestTube}
actions={ actions={
canRun ? (
<Button asChild> <Button asChild>
<Link href={`/studies/${studyId}/trials/new`}> <Link href={`/studies/${studyId}/trials/new`}>
<Plus className="mr-2 h-4 w-4" /> <Plus className="mr-2 h-4 w-4" />
Schedule Trial Schedule Trial
</Link> </Link>
</Button> </Button>
) : null
} }
/> />

View File

@@ -1001,4 +1001,20 @@ export const studiesRouter = createTRPCRouter({
return updatedPlugin; 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;
}),
}); });