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,
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
</CardDescription>
</CardHeader>
<CardContent>
<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" />
<p className="text-sm">View your studies</p>
<Button variant="link" size="sm" asChild className="mt-2">
<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">
<Building className="text-muted-foreground/50 mb-2 h-8 w-8" />
<p className="text-sm">No studies yet</p>
<Button variant="link" size="sm" asChild className="mt-1">
<Link href="/studies/new">Create a study</Link>
</Button>
</div>
)}
{userStudies && userStudies.studies.length > 5 && (
<Button variant="ghost" size="sm" asChild className="w-full">
<Link href="/studies">
Go to Studies <ChevronRight className="ml-1 h-3 w-3" />
View all {userStudies.studies.length} studies <ChevronRight className="ml-1 h-3 w-3" />
</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>

View File

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

View File

@@ -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={
<div className="flex items-center gap-2">
<Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Study
</Link>
</Button>
<Button asChild>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
</Button>
{canManage && (
<Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Study
</Link>
</Button>
)}
{canManage && (
<Button asChild>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
</Button>
)}
</div>
}
/>
@@ -235,12 +244,14 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
icon="FlaskConical"
description="Design and manage experimental protocols for this study"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
</Button>
canManage ? (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
</Button>
) : 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={
<AddMemberDialog studyId={study.id}>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Manage
</Button>
</AddMemberDialog>
canManage ? (
<AddMemberDialog studyId={study.id}>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Manage
</Button>
</AddMemberDialog>
) : null
}
>
<div className="space-y-3">

View File

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

View File

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

View File

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