mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-23 19:27:51 -04:00
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:
@@ -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>
|
||||
<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">View your studies</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-2">
|
||||
<Link href="/studies">
|
||||
Go to Studies <ChevronRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
<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">
|
||||
View all {userStudies.studies.length} studies <ChevronRight className="ml-1 h-3 w-3" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -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={
|
||||
canManage ? (
|
||||
<Button asChild>
|
||||
<a href={`/studies/${studyId}/experiments/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Create Experiment
|
||||
</a>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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">
|
||||
{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={
|
||||
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={
|
||||
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">
|
||||
|
||||
@@ -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={
|
||||
canManage ? (
|
||||
<Button asChild>
|
||||
<a href={`/studies/${studyId}/participants/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Participant
|
||||
</a>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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={
|
||||
canRun ? (
|
||||
<Button asChild>
|
||||
<Link href={`/studies/${studyId}/trials/new`}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Schedule Trial
|
||||
</Link>
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user