mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
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:
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -46,7 +46,7 @@ const getNavItems = (studyId?: number) => [
|
||||
requiresStudy: false,
|
||||
hideWithStudy: true
|
||||
},
|
||||
{
|
||||
{
|
||||
name: "Participants",
|
||||
href: `/dashboard/studies/${studyId}/participants`,
|
||||
icon: UsersRoundIcon,
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
183
src/lib/email.ts
183
src/lib/email.ts
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user