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, studyCount: data.length,
activeInvitationCount: 0 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) { } catch (error) {
console.error("Error fetching stats:", error); console.error("Error fetching stats:", error);
toast({ toast({
@@ -55,7 +44,7 @@ export default function Dashboard() {
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [toast, router, setActiveStudy]); }, [toast]);
useEffect(() => { useEffect(() => {
fetchStats(); fetchStats();

View File

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

View File

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

View File

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

View File

@@ -110,7 +110,7 @@ function getRoleDescription(roleKey: string): string {
PRINCIPAL_INVESTIGATOR: PRINCIPAL_INVESTIGATOR:
"Lead researcher responsible for study design and oversight", "Lead researcher responsible for study design and oversight",
RESEARCHER: 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", WIZARD: "Operator controlling robot behavior during experiments",
OBSERVER: "Team member observing and annotating experiments", OBSERVER: "Team member observing and annotating experiments",
ASSISTANT: "Support staff with limited view access", ASSISTANT: "Support staff with limited view access",

View File

@@ -33,32 +33,179 @@ export async function sendInvitationEmail({
token, token,
}: SendInvitationEmailParams) { }: SendInvitationEmailParams) {
const inviteUrl = `${process.env.NEXT_PUBLIC_APP_URL}/invite/accept/${token}`; 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 = ` const html = `
<h2>You've been invited to join HRIStudio</h2> <!DOCTYPE html>
<p>${inviterName} has invited you to join their study "${studyTitle}" as a ${role}.</p> <html>
<p>HRIStudio is a platform for managing human-robot interaction studies and Wizard-of-Oz experiments.</p> <head>
<p>Click the button below to accept the invitation and join the study:</p> <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=" <a href="${inviteUrl}" style="
background-color: #2563eb;
border-radius: 6px;
color: #ffffff;
display: inline-block; display: inline-block;
background-color: #0070f3; font-size: 16px;
color: white; font-weight: 500;
line-height: 1;
padding: 12px 24px; padding: 12px 24px;
text-decoration: none; text-decoration: none;
border-radius: 6px;
margin: 16px 0;
">Accept Invitation</a> ">Accept Invitation</a>
<p>Or copy and paste this URL into your browser:</p> </td>
<p>${inviteUrl}</p> </tr>
<p>This invitation will expire in 7 days.</p> </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 = ` const text = `
You've been invited to join HRIStudio 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: To accept the invitation, visit this URL:
${inviteUrl} ${inviteUrl}

View File

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