refactor(dashboard, participants, sidebar, users-tab, email): Clean up and enhance component logic and UI

- Removed unused code and comments from the Dashboard component to streamline functionality.
- Updated the ParticipantsList component by removing unnecessary skeleton loaders and table headers for improved clarity.
- Adjusted the Sidebar component to redirect to the main dashboard instead of the studies page when no study is selected.
- Enhanced the UsersTab component to conditionally fetch invitations based on user permissions and improved role management UI.
- Revamped the invitation email template for better presentation and clarity, including a more structured HTML format and improved messaging.
- Updated role descriptions in the seed script for better accuracy and clarity.
This commit is contained in:
2024-12-05 13:49:19 -05:00
parent 8405a49d45
commit 5a1e318df7
7 changed files with 225 additions and 92 deletions

View File

@@ -34,17 +34,6 @@ export default function Dashboard() {
studyCount: data.length,
activeInvitationCount: 0
});
// If there's only one study and we're on the main dashboard, select it
if (data.length === 1) {
const study = {
...data[0],
createdAt: new Date(data[0].createdAt),
updatedAt: data[0].updatedAt ? new Date(data[0].updatedAt) : null
};
setActiveStudy(study);
router.push(`/dashboard/studies/${study.id}`);
}
} catch (error) {
console.error("Error fetching stats:", error);
toast({
@@ -55,7 +44,7 @@ export default function Dashboard() {
} finally {
setIsLoading(false);
}
}, [toast, router, setActiveStudy]);
}, [toast]);
useEffect(() => {
fetchStats();

View File

@@ -166,7 +166,6 @@ export default function ParticipantsList() {
<Table>
<TableHeader>
<TableRow>
<TableHead><Skeleton className="h-4 w-[40px]" /></TableHead>
<TableHead><Skeleton className="h-4 w-[120px]" /></TableHead>
<TableHead><Skeleton className="h-4 w-[100px]" /></TableHead>
<TableHead className="w-[100px]"><Skeleton className="h-4 w-[60px]" /></TableHead>
@@ -175,7 +174,6 @@ export default function ParticipantsList() {
<TableBody>
{[1, 2, 3].map((i) => (
<TableRow key={i}>
<TableCell><Skeleton className="h-4 w-[30px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[150px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-[100px]" /></TableCell>
<TableCell><Skeleton className="h-8 w-8" /></TableCell>
@@ -250,7 +248,6 @@ export default function ParticipantsList() {
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Added</TableHead>
{canDeleteParticipant && <TableHead className="w-[100px]">Actions</TableHead>}
@@ -259,7 +256,6 @@ export default function ParticipantsList() {
<TableBody>
{participants.map((participant) => (
<TableRow key={participant.id}>
<TableCell>{participant.id}</TableCell>
<TableCell>
{canViewNames ? participant.name : `Participant ${participant.id}`}
</TableCell>

View File

@@ -109,7 +109,7 @@ export function Sidebar() {
const handleStudyChange = (value: string) => {
if (value === "all") {
setActiveStudy(null);
router.push("/dashboard/studies");
router.push("/dashboard");
} else {
const study = studies.find(s => s.id.toString() === value);
if (study) {
@@ -131,7 +131,6 @@ export function Sidebar() {
<span className="truncate">
{activeStudy?.title || "All Studies"}
</span>
<ChevronDown className="h-4 w-4 opacity-50" />
</div>
</SelectTrigger>
<SelectContent className="sidebar-dropdown-content">

View File

@@ -91,6 +91,10 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
const fetchInvitations = useCallback(async () => {
try {
const response = await fetch(`/api/invitations?studyId=${studyId}`);
if (response.status === 403) {
// Silently handle 403 errors as they're expected for researchers
return;
}
if (!response.ok) throw new Error("Failed to fetch invitations");
const data = await response.json();
setInvitations(data.data || []);
@@ -109,9 +113,7 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
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)
));
setRoles(data);
} catch (error) {
console.error("Error fetching roles:", error);
toast({
@@ -125,15 +127,15 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
await Promise.all([
fetchUsers(),
fetchInvitations(),
fetchRoles(),
]);
const promises = [fetchUsers(), fetchRoles()];
if (canManageRoles) {
promises.push(fetchInvitations());
}
await Promise.all(promises);
} finally {
setIsLoading(false);
}
}, [fetchUsers, fetchInvitations, fetchRoles]);
}, [fetchUsers, fetchInvitations, fetchRoles, canManageRoles]);
useEffect(() => {
fetchData();
@@ -218,10 +220,15 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
<div>
<CardTitle>Study Members</CardTitle>
<CardDescription>
Manage users and their roles in this study
{canManageRoles ? 'Manage users and their roles in this study' : 'View study members'}
</CardDescription>
</div>
{canManageRoles && <InviteUserDialog studyId={studyId} onInviteSent={fetchInvitations} />}
{canManageRoles && (
<InviteUserDialog
studyId={studyId}
onInviteSent={() => canManageRoles && fetchInvitations()}
/>
)}
</div>
</CardHeader>
<CardContent>
@@ -248,14 +255,15 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
</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>
<Select
value={user.roles[0]?.id.toString()}
onValueChange={(value) => handleRoleChange(user.id, value)}
disabled={!canManageRoles}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
{canManageRoles && (
<SelectContent>
{roles.map((role) => (
<SelectItem key={role.id} value={role.id.toString()}>
@@ -263,10 +271,8 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
</SelectItem>
))}
</SelectContent>
</Select>
) : (
<span>{formatRoleName(user.roles[0]?.name || '')}</span>
)}
)}
</Select>
</TableCell>
</TableRow>
))}
@@ -275,7 +281,7 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
</CardContent>
</Card>
{pendingInvitations.length > 0 && (
{canManageRoles && pendingInvitations.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Pending Invitations</CardTitle>
@@ -290,7 +296,7 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
<TableHead>Email</TableHead>
<TableHead>Role</TableHead>
<TableHead>Expires</TableHead>
{canManageRoles && <TableHead className="w-[100px]">Actions</TableHead>}
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -299,33 +305,31 @@ export function UsersTab({ studyId, permissions }: UsersTabProps) {
<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>
)}
<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>

View File

@@ -110,7 +110,7 @@ function getRoleDescription(roleKey: string): string {
PRINCIPAL_INVESTIGATOR:
"Lead researcher responsible for study design and oversight",
RESEARCHER:
"Study team member with data collection and analysis capabilities",
"Study team member with access to anonymized data and experiment monitoring capabilities",
WIZARD: "Operator controlling robot behavior during experiments",
OBSERVER: "Team member observing and annotating experiments",
ASSISTANT: "Support staff with limited view access",

View File

@@ -33,32 +33,179 @@ export async function sendInvitationEmail({
token,
}: SendInvitationEmailParams) {
const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/accept/${token}`;
const roleDisplay = role
.split('_')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
const html = `
<h2>You've been invited to join HRIStudio</h2>
<p>${inviterName} has invited you to join their study "${studyTitle}" as a ${role}.</p>
<p>HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments.</p>
<p>Click the button below to accept the invitation and join the study:</p>
<a href="${inviteUrl}" style="
display: inline-block;
background-color: #0070f3;
color: white;
padding: 12px 24px;
text-decoration: none;
border-radius: 6px;
margin: 16px 0;
">Accept Invitation</a>
<p>Or copy and paste this URL into your browser:</p>
<p>${inviteUrl}</p>
<p>This invitation will expire in 7 days.</p>
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>HRIStudio Invitation</title>
<style>
@media only screen and (max-width: 620px) {
.content {
padding: 24px !important;
}
}
</style>
</head>
<body style="
background-color: #f3f4f6;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
font-size: 16px;
line-height: 1.5;
margin: 0;
padding: 0;
">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="margin: 0; padding: 20px 0;">
<tr>
<td align="center">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation" style="
max-width: 600px;
margin: 0 auto;
padding: 0;
">
<!-- Logo -->
<tr>
<td align="center" style="padding: 24px;">
<div style="display: flex; align-items: center; justify-content: center; margin-bottom: 8px;">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#6b7280"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
style="margin-right: 4px;"
>
<path d="M12 8V4H8" />
<rect width="16" height="12" x="4" y="8" rx="2" />
<path d="M2 14h2" />
<path d="M20 14h2" />
<path d="M15 13v2" />
<path d="M9 13v2" />
</svg>
<h1 style="
color: #111827;
font-size: 24px;
font-weight: bold;
margin: 0;
display: flex;
">
<span style="font-weight: 800;">HRI</span>
<span style="font-weight: 400;">Studio</span>
</h1>
</div>
<p style="
color: #6b7280;
font-size: 14px;
margin: 8px 0 0;
">A platform for managing human-robot interaction studies</p>
</td>
</tr>
<!-- Main Content -->
<tr>
<td class="content" style="
background-color: #ffffff;
padding: 32px;
border-radius: 8px;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
">
<h2 style="
color: #111827;
font-size: 20px;
font-weight: 600;
margin: 0 0 16px;
">You've been invited to join a research study</h2>
<p style="margin: 16px 0; color: #374151;">
${inviterName} has invited you to join <strong>"${studyTitle}"</strong> as a ${roleDisplay}.
</p>
<p style="margin: 16px 0; color: #374151;">
HRIStudio helps research teams manage human-robot interaction studies and conduct Wizard-of-Oz experiments efficiently.
</p>
<!-- Button -->
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center" style="padding: 24px 0;">
<a href="${inviteUrl}" style="
background-color: #2563eb;
border-radius: 6px;
color: #ffffff;
display: inline-block;
font-size: 16px;
font-weight: 500;
line-height: 1;
padding: 12px 24px;
text-decoration: none;
">Accept Invitation</a>
</td>
</tr>
</table>
<p style="
margin: 16px 0 0;
padding-top: 16px;
border-top: 1px solid #e5e7eb;
color: #6b7280;
font-size: 14px;
">
If you're having trouble with the button above, copy and paste the URL below into your web browser:
</p>
<p style="
margin: 8px 0;
color: #6b7280;
font-size: 14px;
word-break: break-all;
">
${inviteUrl}
</p>
<p style="
margin: 24px 0 0;
color: #6b7280;
font-size: 14px;
font-style: italic;
">
This invitation will expire in 7 days.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding: 24px; text-align: center;">
<p style="
color: #6b7280;
font-size: 14px;
margin: 0;
">
This is an automated message from HRIStudio. Please do not reply to this email.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
const text = `
You've been invited to join HRIStudio
${inviterName} has invited you to join their study "${studyTitle}" as a ${role}.
${inviterName} has invited you to join "${studyTitle}" as a ${roleDisplay}.
HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments.
HRIStudio helps research teams manage human-robot interaction studies and conduct Wizard-of-Oz experiments efficiently.
To accept the invitation, visit this URL:
${inviteUrl}

View File

@@ -34,8 +34,6 @@ export const ROLE_PERMISSIONS: Record<RoleCode, Array<keyof typeof PERMISSIONS>>
RESEARCHER: [
'VIEW_STUDY',
'CREATE_PARTICIPANT',
'EDIT_PARTICIPANT',
'VIEW_ROBOT_STATUS',
'VIEW_EXPERIMENT',
'VIEW_EXPERIMENT_DATA',