fix: update user fields to match schema

- Replace firstName/lastName with name field in users API route
- Update user formatting in UsersTab component
- Add email fallback when name is not available
This commit is contained in:
2024-12-04 14:45:24 -05:00
parent 95b106d9e9
commit 29ce631901
36 changed files with 2700 additions and 167 deletions

View File

@@ -0,0 +1,93 @@
'use client';
import { ChevronRight } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useActiveStudy } from "~/context/active-study";
interface BreadcrumbItem {
label: string;
href?: string;
}
export function Breadcrumb() {
const pathname = usePathname();
const { activeStudy } = useActiveStudy();
const getBreadcrumbs = (): BreadcrumbItem[] => {
const items: BreadcrumbItem[] = [{ label: 'Dashboard', href: '/dashboard' }];
const path = pathname.split('/').filter(Boolean);
// Handle studies list page
if (path[1] === 'studies' && !activeStudy) {
items.push({ label: 'Studies', href: '/dashboard/studies' });
if (path[2] === 'new') {
items.push({ label: 'New Study' });
}
return items;
}
// Handle active study pages
if (activeStudy) {
items.push({
label: 'Studies',
href: '/dashboard/studies'
});
items.push({
label: activeStudy.title,
href: `/dashboard/studies/${activeStudy.id}`
});
// Add section based on URL
if (path.length > 3) {
const section = path[3];
const sectionLabel = section.charAt(0).toUpperCase() + section.slice(1);
if (section === 'new') {
items.push({
label: `New ${path[2].slice(0, -1)}`,
href: `/dashboard/studies/${activeStudy.id}/${path[2]}/new`
});
} else {
items.push({
label: sectionLabel,
href: `/dashboard/studies/${activeStudy.id}/${section}`
});
}
}
}
return items;
};
const breadcrumbs = getBreadcrumbs();
if (breadcrumbs.length <= 1) return null;
return (
<div className="flex items-center space-x-2 text-sm text-muted-foreground mb-6">
{breadcrumbs.map((item, index) => {
const isLast = index === breadcrumbs.length - 1;
return (
<div key={item.label} className="flex items-center">
{index > 0 && <ChevronRight className="h-4 w-4 mx-2" />}
{item.href && !isLast ? (
<Link
href={item.href}
className="hover:text-foreground transition-colors"
>
{item.label}
</Link>
) : (
<span className={isLast ? "text-foreground font-medium" : ""}>
{item.label}
</span>
)}
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -5,50 +5,172 @@ import {
BarChartIcon,
UsersRoundIcon,
LandPlotIcon,
BotIcon,
FolderIcon,
FileTextIcon,
LayoutDashboard,
Menu,
Settings
Settings,
ChevronDown,
FolderIcon,
PlusIcon
} from "lucide-react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { usePathname, useRouter } from "next/navigation"
import { useState } from "react"
import { Button } from "~/components/ui/button"
import { Sheet, SheetContent, SheetTrigger, SheetTitle } from "~/components/ui/sheet"
import { cn } from "~/lib/utils"
import { Logo } from "~/components/logo"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select"
import { Separator } from "~/components/ui/separator"
import { useActiveStudy } from "~/context/active-study"
const navItems = [
{ name: "Dashboard", href: "/dashboard", icon: LayoutDashboard },
{ name: "Studies", href: "/dashboard/studies", icon: FolderIcon },
{ name: "Trials", href: "/dashboard/trials", icon: LandPlotIcon },
{ name: "Forms", href: "/dashboard/forms", icon: FileTextIcon },
{ name: "Data Analysis", href: "/dashboard/analysis", icon: BarChartIcon },
{ name: "Settings", href: "/dashboard/settings", icon: Settings },
const getNavItems = (studyId?: number) => [
{
name: "Dashboard",
href: studyId ? `/dashboard/studies/${studyId}` : "/dashboard",
icon: LayoutDashboard,
exact: true,
requiresStudy: false
},
{
name: "Participants",
href: `/dashboard/studies/${studyId}/participants`,
icon: UsersRoundIcon,
requiresStudy: true,
baseRoute: "participants"
},
{
name: "Trials",
href: `/dashboard/studies/${studyId}/trials`,
icon: LandPlotIcon,
requiresStudy: true,
baseRoute: "trials"
},
{
name: "Forms",
href: `/dashboard/studies/${studyId}/forms`,
icon: FileTextIcon,
requiresStudy: true,
baseRoute: "forms"
},
{
name: "Data Analysis",
href: `/dashboard/studies/${studyId}/analysis`,
icon: BarChartIcon,
requiresStudy: true,
baseRoute: "analysis"
},
{
name: "Settings",
href: `/dashboard/studies/${studyId}/settings`,
icon: Settings,
requiresStudy: true,
baseRoute: "settings"
},
];
export function Sidebar() {
const pathname = usePathname()
const router = useRouter()
const [isOpen, setIsOpen] = useState(false)
const { user } = useUser()
const { activeStudy, setActiveStudy, studies, isLoading } = useActiveStudy()
const navItems = getNavItems(activeStudy?.id)
const visibleNavItems = activeStudy
? navItems
: navItems.filter(item => !item.requiresStudy)
const isActiveRoute = (item: { href: string, exact?: boolean, baseRoute?: string }) => {
if (item.exact) {
return pathname === item.href;
}
if (item.baseRoute && activeStudy) {
const pattern = new RegExp(`/dashboard/studies/\\d+/${item.baseRoute}`);
return pattern.test(pathname);
}
return pathname.startsWith(item.href);
};
const handleStudyChange = (value: string) => {
if (value === "all") {
setActiveStudy(null);
router.push("/dashboard/studies");
} else {
const study = studies.find(s => s.id.toString() === value);
if (study) {
setActiveStudy(study);
router.push(`/dashboard/studies/${study.id}`);
}
}
};
const SidebarContent = () => (
<div className="flex h-full flex-col bg-gradient-to-b from-[hsl(var(--sidebar-background-top))] to-[hsl(var(--sidebar-background-bottom))]">
<div className="flex h-full flex-col">
<div className="p-4">
<Select
value={activeStudy?.id?.toString() || "all"}
onValueChange={handleStudyChange}
>
<SelectTrigger className="w-full sidebar-button">
<div className="flex items-center justify-between">
<span className="truncate">
{activeStudy?.title || "All Studies"}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</SelectTrigger>
<SelectContent className="sidebar-dropdown-content">
<SelectItem value="all" className="sidebar-button">
<div className="flex items-center">
<FolderIcon className="h-4 w-4 mr-2" />
All Studies
</div>
</SelectItem>
<Separator className="sidebar-separator" />
{studies.map((study) => (
<SelectItem
key={study.id}
value={study.id.toString()}
className="sidebar-button"
>
{study.title}
</SelectItem>
))}
<Separator className="sidebar-separator" />
<Button
variant="ghost"
className="w-full justify-start sidebar-button"
asChild
>
<Link href="/dashboard/studies/new">
<PlusIcon className="h-4 w-4 mr-2" />
Create New Study
</Link>
</Button>
</SelectContent>
</Select>
</div>
<nav className="flex-1 overflow-y-auto p-4">
<ul className="space-y-2">
{navItems.map((item) => {
{visibleNavItems.map((item) => {
const IconComponent = item.icon;
const isActive = isActiveRoute(item);
return (
<li key={item.href}>
<Button
asChild
variant="ghost"
className={cn(
"w-full justify-start text-[hsl(var(--sidebar-foreground))] hover:bg-[hsl(var(--sidebar-hover))]",
pathname === item.href && "bg-[hsl(var(--sidebar-hover))] font-semibold"
)}
className="w-full justify-start sidebar-button"
data-active={isActive}
>
<Link href={item.href} onClick={() => setIsOpen(false)}>
<IconComponent className="h-5 w-5 mr-3" />
@@ -60,13 +182,20 @@ export function Sidebar() {
})}
</ul>
</nav>
<div className="border-t p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<UserButton />
<div>
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))]">{user?.fullName ?? 'User'}</p>
<p className="text-xs text-[hsl(var(--sidebar-muted))]">{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}</p>
<div className="p-4">
<div className="border-t border-[hsl(var(--sidebar-separator))]">
<div className="flex items-center justify-between pt-4">
<div className="flex items-center space-x-4">
<UserButton />
<div>
<p className="text-sm font-medium text-[hsl(var(--sidebar-foreground))]">
{user?.fullName ?? user?.username ?? 'User'}
</p>
<p className="text-xs text-[hsl(var(--sidebar-muted))]">
{user?.primaryEmailAddress?.emailAddress ?? 'user@example.com'}
</p>
</div>
</div>
</div>
</div>
@@ -77,7 +206,7 @@ export function Sidebar() {
return (
<>
<div className="lg:hidden fixed top-0 left-0 right-0 z-50">
<div className="flex h-14 items-center justify-between border-b px-4 bg-background">
<div className="flex h-14 items-center justify-between border-b border-[hsl(var(--sidebar-border))]">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"
@@ -85,19 +214,22 @@ export function Sidebar() {
/>
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetTrigger asChild>
<Button variant="ghost" className="h-14 w-14 px-0">
<Button variant="ghost" className="h-14 w-14 px-0 sidebar-button">
<Menu className="h-6 w-6" />
</Button>
</SheetTrigger>
<SheetContent side="top" className="w-full">
<SheetContent
side="left"
className="w-full p-0 border-[hsl(var(--sidebar-border))]"
>
<SheetTitle className="sr-only">Navigation Menu</SheetTitle>
<SidebarContent />
</SheetContent>
</Sheet>
</div>
</div>
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:bg-gradient-to-b lg:from-[hsl(var(--sidebar-background-top))] lg:to-[hsl(var(--sidebar-background-bottom))]">
<div className="flex h-14 items-center border-b px-4">
<div className="hidden lg:flex lg:w-64 lg:flex-col lg:border-r lg:border-[hsl(var(--sidebar-border))]">
<div className="flex h-14 items-center border-b border-[hsl(var(--sidebar-border))] px-4">
<Logo
href="/dashboard"
className="text-[hsl(var(--sidebar-foreground))]"

View File

@@ -0,0 +1,136 @@
'use client';
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import { InviteUserDialog } from "./invite-user-dialog";
interface Invitation {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
interface InvitationsTabProps {
studyId: number;
permissions: string[];
}
export function InvitationsTab({ studyId, permissions }: InvitationsTabProps) {
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canManageRoles = hasPermission(PERMISSIONS.MANAGE_ROLES);
useEffect(() => {
fetchInvitations();
}, [studyId]);
const fetchInvitations = async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
} catch (error) {
console.error("Error fetching invitations:", error);
toast({
title: "Error",
description: "Failed to load invitations",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
const handleDeleteInvitation = async (invitationId: string) => {
try {
const response = await fetch(`/api/invitations/${invitationId}`, {
method: "DELETE",
});
if (!response.ok) {
throw new Error("Failed to delete invitation");
}
setInvitations(invitations.filter(inv => inv.id !== invitationId));
toast({
title: "Success",
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
};
if (isLoading) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">Loading invitations...</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Manage Invitations</CardTitle>
<CardDescription>
Invite researchers and participants to collaborate on this study
</CardDescription>
</div>
<InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />
</div>
</CardHeader>
<CardContent className="space-y-6">
{invitations.length > 0 ? (
<div className="space-y-4">
{invitations.map((invitation) => (
<div
key={invitation.id}
className="flex items-center justify-between p-4 border rounded-lg bg-card"
>
<div>
<p className="font-medium">{invitation.email}</p>
<p className="text-sm text-muted-foreground">
Role: {invitation.roleName}
{invitation.accepted ? " • Accepted" : " • Pending"}
</p>
</div>
{!invitation.accepted && (
<Button
variant="outline"
size="sm"
onClick={() => handleDeleteInvitation(invitation.id)}
>
Cancel
</Button>
)}
</div>
))}
</div>
) : (
<p className="text-muted-foreground">No invitations sent yet.</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useToast } from "~/hooks/use-toast";
interface Role {
id: number;
name: string;
description: string;
}
interface InviteUserDialogProps {
studyId: number;
onInviteSent: () => void;
}
export function InviteUserDialog({ studyId, onInviteSent }: InviteUserDialogProps) {
const [email, setEmail] = useState("");
const [roleId, setRoleId] = useState<string>("");
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [roles, setRoles] = useState<Role[]>([]);
const { toast } = useToast();
// Fetch available roles when dialog opens
useEffect(() => {
if (isOpen) {
fetchRoles();
}
}, [isOpen]);
const fetchRoles = async () => {
try {
const response = await fetch("/api/roles");
if (!response.ok) {
throw new Error("Failed to fetch roles");
}
const data = await response.json();
// Filter out admin and PI roles
setRoles(data.filter((role: Role) =>
!['admin', 'principal_investigator'].includes(role.name)
));
} catch (error) {
console.error("Error fetching roles:", error);
toast({
title: "Error",
description: "Failed to load roles",
variant: "destructive",
});
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !roleId) return;
setIsLoading(true);
try {
const response = await fetch("/api/invitations", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
email,
roleId: parseInt(roleId, 10),
studyId,
}),
});
if (!response.ok) {
throw new Error("Failed to send invitation");
}
toast({
title: "Success",
description: "Invitation sent successfully",
});
setIsOpen(false);
setEmail("");
setRoleId("");
onInviteSent();
} catch (error) {
console.error("Error sending invitation:", error);
toast({
title: "Error",
description: "Failed to send invitation",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild>
<Button>Invite User</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Invite User</DialogTitle>
<DialogDescription>
Send an invitation to collaborate on this study
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter email address"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="role">Role</Label>
<Select value={roleId} onValueChange={setRoleId} required>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{role.name.split('_').map(word =>
word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
).join(' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setIsOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Sending..." : "Send Invitation"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -38,6 +38,11 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
const [error, setError] = useState<string | null>(null);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canCreateParticipant = hasPermission(PERMISSIONS.CREATE_PARTICIPANT);
const canDeleteParticipant = hasPermission(PERMISSIONS.DELETE_PARTICIPANT);
const canViewNames = hasPermission(PERMISSIONS.VIEW_PARTICIPANT_NAMES);
useEffect(() => {
fetchParticipants();
}, [studyId]);
@@ -121,8 +126,6 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
}
};
const hasPermission = (permission: string) => permissions.includes(permission);
if (isLoading) {
return (
<Card>
@@ -133,19 +136,9 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
);
}
if (error) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-destructive">{error}</p>
</CardContent>
</Card>
);
}
return (
<div className="space-y-6">
{hasPermission(PERMISSIONS.CREATE_PARTICIPANT) && (
{canCreateParticipant && (
<Card>
<CardHeader>
<CardTitle>Add New Participant</CardTitle>
@@ -181,12 +174,13 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
<div>
<h3 className="font-semibold">
{participant.name}
{!canViewNames && <span className="text-sm text-muted-foreground ml-2">(ID: {participant.id})</span>}
</h3>
<p className="text-sm text-muted-foreground">
Added {new Date(participant.createdAt).toLocaleDateString()}
</p>
</div>
{hasPermission(PERMISSIONS.DELETE_PARTICIPANT) && (
{canDeleteParticipant && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm">
@@ -222,7 +216,7 @@ export function ParticipantsTab({ studyId, permissions }: ParticipantsTabProps)
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
No participants added yet. Add your first participant above.
No participants added yet{canCreateParticipant ? ". Add your first participant above" : ""}.
</p>
</CardContent>
</Card>

View File

@@ -8,6 +8,7 @@ import { Button } from "~/components/ui/button";
import { useToast } from "~/hooks/use-toast";
import { useState } from "react";
import { PERMISSIONS } from "~/lib/permissions-client";
import { useRouter } from "next/navigation";
interface SettingsTabProps {
study: {
@@ -21,13 +22,18 @@ interface SettingsTabProps {
export function SettingsTab({ study }: SettingsTabProps) {
const [title, setTitle] = useState(study.title);
const [description, setDescription] = useState(study.description || "");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const router = useRouter();
const hasPermission = (permission: string) => study.permissions.includes(permission);
const canEditStudy = hasPermission(PERMISSIONS.EDIT_STUDY);
const updateStudy = async (e: React.FormEvent) => {
e.preventDefault();
if (!canEditStudy) return;
setIsLoading(true);
try {
const response = await fetch(`/api/studies/${study.id}`, {
method: "PATCH",
@@ -37,7 +43,13 @@ export function SettingsTab({ study }: SettingsTabProps) {
body: JSON.stringify({ title, description }),
});
if (!response.ok) throw new Error("Failed to update study");
if (!response.ok) {
if (response.status === 403) {
router.push('/dashboard/studies');
return;
}
throw new Error("Failed to update study");
}
toast({
title: "Success",
@@ -47,12 +59,26 @@ export function SettingsTab({ study }: SettingsTabProps) {
console.error("Error updating study:", error);
toast({
title: "Error",
description: "Failed to update study",
description: error instanceof Error ? error.message : "Failed to update study",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
if (!canEditStudy) {
return (
<Card>
<CardContent className="py-8">
<p className="text-center text-muted-foreground">
You don't have permission to edit this study.
</p>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
@@ -69,7 +95,7 @@ export function SettingsTab({ study }: SettingsTabProps) {
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter study title"
required
disabled={!canEditStudy}
disabled={isLoading}
/>
</div>
<div className="space-y-2">
@@ -79,14 +105,12 @@ export function SettingsTab({ study }: SettingsTabProps) {
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter study description"
disabled={!canEditStudy}
disabled={isLoading}
/>
</div>
{canEditStudy && (
<Button type="submit">
Save Changes
</Button>
)}
<Button type="submit" disabled={isLoading}>
{isLoading ? "Saving..." : "Save Changes"}
</Button>
</form>
</CardContent>
</Card>

View File

@@ -0,0 +1,336 @@
'use client';
import { useState, useEffect } from "react";
import { UserAvatar } from "~/components/user-avatar";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { useToast } from "~/hooks/use-toast";
import { PERMISSIONS } from "~/lib/permissions-client";
import { InviteUserDialog } from "./invite-user-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { Trash2Icon } from "lucide-react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
interface User {
id: string;
email: string;
firstName: string | null;
lastName: string | null;
roles: Array<{ id: number; name: string }>;
}
interface Invitation {
id: string;
email: string;
roleName: string;
accepted: boolean;
expiresAt: string;
}
interface Role {
id: number;
name: string;
description: string;
}
interface UsersTabProps {
studyId: number;
permissions: string[];
}
export function UsersTab({ studyId, permissions }: UsersTabProps) {
const [users, setUsers] = useState<User[]>([]);
const [invitations, setInvitations] = useState<Invitation[]>([]);
const [roles, setRoles] = useState<Role[]>([]);
const [isLoading, setIsLoading] = useState(true);
const { toast } = useToast();
const hasPermission = (permission: string) => permissions.includes(permission);
const canManageRoles = hasPermission(PERMISSIONS.MANAGE_ROLES);
useEffect(() => {
fetchData();
}, [studyId]);
const fetchData = async () => {
try {
await Promise.all([
fetchUsers(),
fetchInvitations(),
fetchRoles(),
]);
} finally {
setIsLoading(false);
}
};
const fetchUsers = async () => {
try {
const response = await fetch(`/api/studies/${studyId}/users`);
if (!response.ok) throw new Error("Failed to fetch users");
const data = await response.json();
setUsers(data.data || []);
} catch (error) {
console.error("Error fetching users:", error);
toast({
title: "Error",
description: "Failed to load users",
variant: "destructive",
});
}
};
const fetchInvitations = async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
} catch (error) {
console.error("Error fetching invitations:", error);
toast({
title: "Error",
description: "Failed to load invitations",
variant: "destructive",
});
}
};
const fetchRoles = async () => {
try {
const response = await fetch("/api/roles");
if (!response.ok) throw new Error("Failed to fetch roles");
const data = await response.json();
setRoles(data.filter((role: Role) =>
!['admin'].includes(role.name)
));
} catch (error) {
console.error("Error fetching roles:", error);
toast({
title: "Error",
description: "Failed to load roles",
variant: "destructive",
});
}
};
const handleRoleChange = async (userId: string, newRoleId: string) => {
try {
const response = await fetch(`/api/studies/${studyId}/users/${userId}/role`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
roleId: parseInt(newRoleId, 10),
}),
});
if (!response.ok) throw new Error("Failed to update role");
toast({
title: "Success",
description: "User role updated successfully",
});
// Refresh users list
fetchUsers();
} catch (error) {
console.error("Error updating role:", error);
toast({
title: "Error",
description: "Failed to update user role",
variant: "destructive",
});
}
};
const handleDeleteInvitation = async (invitationId: string) => {
try {
const response = await fetch(`/api/invitations/${invitationId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error("Failed to delete invitation");
setInvitations(invitations.filter(inv => inv.id !== invitationId));
toast({
title: "Success",
description: "Invitation deleted successfully",
});
} catch (error) {
console.error("Error deleting invitation:", error);
toast({
title: "Error",
description: "Failed to delete invitation",
variant: "destructive",
});
}
};
const formatName = (user: User) => {
return user.name || user.email;
};
const formatRoleName = (name: string) => {
return name
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
};
if (isLoading) {
return <div>Loading...</div>;
}
const pendingInvitations = invitations.filter(inv => !inv.accepted);
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold tracking-tight">Team Members</h2>
{canManageRoles && <InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />}
</div>
<Card>
<CardHeader>
<CardTitle>Study Members</CardTitle>
<CardDescription>
Manage users and their roles in this study
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>User</TableHead>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{users.map((user) => (
<TableRow key={user.id}>
<TableCell className="flex items-center gap-2">
<UserAvatar
user={{
name: formatName(user),
email: user.email,
}}
/>
<span>{formatName(user)}</span>
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
{canManageRoles ? (
<Select
value={user.roles[0]?.id.toString()}
onValueChange={(value) => handleRoleChange(user.id, value)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
{formatRoleName(role.name)}
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{formatRoleName(user.roles[0]?.name || '')}</span>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{pendingInvitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
<CardDescription>
Outstanding invitations to join the study
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expires</TableHead>
{canManageRoles && <TableHead className="w-[100px]">Actions</TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{pendingInvitations.map((invitation) => (
<TableRow key={invitation.id}>
<TableCell>{invitation.email}</TableCell>
<TableCell>{formatRoleName(invitation.roleName)}</TableCell>
<TableCell>{new Date(invitation.expiresAt).toLocaleDateString()}</TableCell>
{canManageRoles && (
<TableCell>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="icon">
<Trash2Icon className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Invitation</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this invitation? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteInvitation(invitation.id)}
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</TableCell>
)}
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "~/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,114 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cn } from "~/lib/utils"
import { ChevronRightIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"
const Breadcrumb = React.forwardRef<
HTMLElement,
React.ComponentPropsWithoutRef<"nav"> & {
separator?: React.ReactNode
}
>(({ ...props }, ref) => <nav ref={ref} aria-label="breadcrumb" {...props} />)
Breadcrumb.displayName = "Breadcrumb"
const BreadcrumbList = React.forwardRef<
HTMLOListElement,
React.ComponentPropsWithoutRef<"ol">
>(({ className, ...props }, ref) => (
<ol
ref={ref}
className={cn(
"flex flex-wrap items-center gap-1.5 break-words text-sm text-muted-foreground sm:gap-2.5",
className
)}
{...props}
/>
))
BreadcrumbList.displayName = "BreadcrumbList"
const BreadcrumbItem = React.forwardRef<
HTMLLIElement,
React.ComponentPropsWithoutRef<"li">
>(({ className, ...props }, ref) => (
<li
ref={ref}
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
))
BreadcrumbItem.displayName = "BreadcrumbItem"
const BreadcrumbLink = React.forwardRef<
HTMLAnchorElement,
React.ComponentPropsWithoutRef<"a"> & {
asChild?: boolean
}
>(({ asChild, className, ...props }, ref) => {
const Comp = asChild ? Slot : "a"
return (
<Comp
ref={ref}
className={cn("transition-colors hover:text-foreground", className)}
{...props}
/>
)
})
BreadcrumbLink.displayName = "BreadcrumbLink"
const BreadcrumbPage = React.forwardRef<
HTMLSpanElement,
React.ComponentPropsWithoutRef<"span">
>(({ className, ...props }, ref) => (
<span
ref={ref}
role="link"
aria-disabled="true"
aria-current="page"
className={cn("font-normal text-foreground", className)}
{...props}
/>
))
BreadcrumbPage.displayName = "BreadcrumbPage"
const BreadcrumbSeparator = ({
children,
className,
...props
}: React.ComponentProps<"li">) => (
<li
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:w-3.5 [&>svg]:h-3.5", className)}
{...props}
>
{children ?? <ChevronRightIcon />}
</li>
)
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
const BreadcrumbEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
role="presentation"
aria-hidden="true"
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<DotsHorizontalIcon className="h-4 w-4" />
<span className="sr-only">More</span>
</span>
)
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "~/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,15 @@
import { cn } from "~/lib/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

120
src/components/ui/table.tsx Normal file
View File

@@ -0,0 +1,120 @@
import * as React from "react"
import { cn } from "~/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,27 @@
import { Avatar, AvatarFallback } from "~/components/ui/avatar";
interface UserAvatarProps {
user: {
name?: string | null;
email: string;
};
className?: string;
}
export function UserAvatar({ user, className }: UserAvatarProps) {
function getInitials(name: string) {
return name
.split(' ')
.map(part => part[0])
.join('')
.toUpperCase();
}
const initials = user.name ? getInitials(user.name) : user.email[0].toUpperCase();
return (
<Avatar className={className}>
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
);
}