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,
|
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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user