Files
maternal-app/maternal-web/components/family/RoleInvitesSection.tsx
Andrei 40dbb2287a
Some checks failed
ParentFlow CI/CD Pipeline / Backend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Frontend Tests (push) Has been cancelled
ParentFlow CI/CD Pipeline / Security Scanning (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-app/maternal-app-backend dockerfile:Dockerfile.production name:backend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Build Docker Images (map[context:maternal-web dockerfile:Dockerfile.production name:frontend]) (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Development (push) Has been cancelled
ParentFlow CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled
CI/CD Pipeline / Build Application (push) Has been cancelled
feat: Implement comprehensive onboarding improvements with role-based family invites
This commit adds a complete onboarding improvements system including progress
tracking, streamlined UI, and role-based family invitation system.

## Backend Changes

### Database Migrations
- Add onboarding tracking fields to users table (onboarding_completed, onboarding_step, onboarding_data)
- Add role-based invite codes to families table (parent/caregiver/viewer codes with expiration)
- Add indexes for fast invite code lookups

### User Preferences Module
- Add UserPreferencesController with onboarding endpoints
- Add UserPreferencesService with progress tracking methods
- Add UpdateOnboardingProgressDto for validation
- Endpoints: GET/PUT /api/v1/preferences/onboarding, POST /api/v1/preferences/onboarding/complete

### Families Module - Role-Based Invites
- Add generateRoleInviteCode() - Generate role-specific codes with expiration
- Add getRoleInviteCodes() - Retrieve all active codes for a family
- Add joinFamilyWithRoleCode() - Join family with automatic role assignment
- Add revokeRoleInviteCode() - Revoke specific role invite codes
- Add sendEmailInvite() - Generate code and send email invitation
- Endpoints: POST/GET/DELETE /api/v1/families/:id/invite-codes, POST /api/v1/families/join-with-role, POST /api/v1/families/:id/email-invite

### Email Service
- Add sendFamilyInviteEmail() - Send role-based invitation emails
- Beautiful HTML templates with role badges (👨‍👩‍👧 parent, 🤝 caregiver, 👁️ viewer)
- Role-specific permission descriptions
- Graceful fallback if email sending fails

### Auth Service
- Fix duplicate family creation bug in joinFamily()
- Ensure users only join family once during onboarding

## Frontend Changes

### Onboarding Page
- Reduce steps from 5 to 4 (combined language + measurements)
- Replace card-based selection with dropdown selectors
- Add automatic progress saving after each step
- Add progress restoration on page mount
- Extract FamilySetupStep into reusable component

### Family Page
- Add RoleInvitesSection component with accordion UI
- Generate/view/copy/regenerate/revoke controls for each role
- Send email invites directly from UI
- Display expiration dates (e.g., "Expires in 5 days")
- Info tooltips explaining role permissions
- Only visible to users with parent role

### API Client
- Add role-based invite methods to families API
- Add onboarding progress methods to users API
- TypeScript interfaces for all new data structures

## Features

 Streamlined 4-step onboarding with dropdown selectors
 Automatic progress save/restore across sessions
 Role-based family invites (parent/caregiver/viewer)
 Beautiful email invitations with role descriptions
 Automatic role assignment when joining with invite codes
 Granular permission control per role
 Email fallback if sending fails
 All changes tested and production-ready

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 15:25:16 +00:00

368 lines
11 KiB
TypeScript

'use client';
import { useState, useEffect } from 'react';
import {
Box,
Paper,
Typography,
Button,
Chip,
IconButton,
Tooltip,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Alert,
CircularProgress,
Accordion,
AccordionSummary,
AccordionDetails,
Stack,
} from '@mui/material';
import {
ContentCopy,
Refresh,
Delete,
Email,
ExpandMore,
Info,
} from '@mui/icons-material';
import { familiesApi } from '@/lib/api/families';
interface RoleInvitesSectionProps {
familyId: string;
onSuccess?: (message: string) => void;
onError?: (message: string) => void;
}
interface RoleCode {
code: string;
expiresAt: Date;
}
interface RoleCodes {
parent?: RoleCode;
caregiver?: RoleCode;
viewer?: RoleCode;
}
const roleInfo = {
parent: {
icon: '👨‍👩‍👧',
label: 'Parent',
description: 'Full access - manage children, log activities, view reports, and invite others',
color: 'primary' as const,
},
caregiver: {
icon: '🤝',
label: 'Caregiver',
description: 'Can edit children, log activities, and view reports (cannot add children or invite others)',
color: 'secondary' as const,
},
viewer: {
icon: '👁️',
label: 'Viewer',
description: 'View-only access - can view reports but cannot log activities',
color: 'info' as const,
},
};
export function RoleInvitesSection({ familyId, onSuccess, onError }: RoleInvitesSectionProps) {
const [roleCodes, setRoleCodes] = useState<RoleCodes>({});
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [emailDialogOpen, setEmailDialogOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<'parent' | 'caregiver' | 'viewer' | null>(null);
const [recipientEmail, setRecipientEmail] = useState('');
const [emailSending, setEmailSending] = useState(false);
useEffect(() => {
fetchRoleCodes();
}, [familyId]);
const fetchRoleCodes = async () => {
try {
setLoading(true);
const codes = await familiesApi.getRoleInviteCodes(familyId);
setRoleCodes(codes);
} catch (err: any) {
console.error('Failed to fetch role codes:', err);
onError?.('Failed to load invite codes');
} finally {
setLoading(false);
}
};
const handleGenerateCode = async (role: 'parent' | 'caregiver' | 'viewer') => {
try {
setActionLoading(role);
const result = await familiesApi.generateRoleInviteCode(familyId, role, 7);
setRoleCodes(prev => ({
...prev,
[role]: { code: result.code, expiresAt: result.expiresAt },
}));
onSuccess?.(`${roleInfo[role].label} invite code generated`);
} catch (err: any) {
console.error('Failed to generate code:', err);
onError?.('Failed to generate invite code');
} finally {
setActionLoading(null);
}
};
const handleCopyCode = async (code: string, role: string) => {
try {
await navigator.clipboard.writeText(code);
onSuccess?.(`${roleInfo[role as keyof typeof roleInfo].label} code copied to clipboard`);
} catch (err) {
onError?.('Failed to copy code');
}
};
const handleRevokeCode = async (role: 'parent' | 'caregiver' | 'viewer') => {
try {
setActionLoading(role);
await familiesApi.revokeRoleInviteCode(familyId, role);
setRoleCodes(prev => {
const updated = { ...prev };
delete updated[role];
return updated;
});
onSuccess?.(`${roleInfo[role].label} invite code revoked`);
} catch (err: any) {
console.error('Failed to revoke code:', err);
onError?.('Failed to revoke invite code');
} finally {
setActionLoading(null);
}
};
const handleOpenEmailDialog = (role: 'parent' | 'caregiver' | 'viewer') => {
setSelectedRole(role);
setRecipientEmail('');
setEmailDialogOpen(true);
};
const handleSendEmailInvite = async () => {
if (!selectedRole || !recipientEmail) return;
try {
setEmailSending(true);
const result = await familiesApi.sendEmailInvite(familyId, recipientEmail, selectedRole);
// Update the codes
setRoleCodes(prev => ({
...prev,
[selectedRole]: { code: result.code, expiresAt: result.expiresAt },
}));
if (result.emailSent) {
onSuccess?.(`Invite email sent to ${recipientEmail}`);
} else {
onSuccess?.('Invite code generated, but email could not be sent');
}
setEmailDialogOpen(false);
setRecipientEmail('');
setSelectedRole(null);
} catch (err: any) {
console.error('Failed to send email invite:', err);
onError?.(err.response?.data?.message || 'Failed to send email invite');
} finally {
setEmailSending(false);
}
};
const formatExpiryDate = (expiresAt: Date) => {
const date = new Date(expiresAt);
const now = new Date();
const diffMs = date.getTime() - now.getTime();
const diffDays = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (diffDays < 0) return 'Expired';
if (diffDays === 0) return 'Expires today';
if (diffDays === 1) return 'Expires tomorrow';
return `Expires in ${diffDays} days`;
};
const renderRoleCodeCard = (role: 'parent' | 'caregiver' | 'viewer') => {
const info = roleInfo[role];
const codeData = roleCodes[role];
const isLoading = actionLoading === role;
return (
<Accordion key={role} sx={{ mb: 2, borderRadius: 2, '&:before': { display: 'none' } }}>
<AccordionSummary
expandIcon={<ExpandMore />}
sx={{
borderRadius: 2,
'&.Mui-expanded': { borderBottomLeftRadius: 0, borderBottomRightRadius: 0 },
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, width: '100%' }}>
<Typography sx={{ fontSize: '1.5rem' }}>{info.icon}</Typography>
<Box sx={{ flex: 1 }}>
<Typography variant="subtitle1" fontWeight="600">
{info.label}
</Typography>
<Typography variant="caption" color="text.secondary">
{codeData ? formatExpiryDate(codeData.expiresAt) : 'No active code'}
</Typography>
</Box>
{codeData && (
<Chip
label={codeData.code}
color={info.color}
sx={{ fontWeight: 600, letterSpacing: 1 }}
/>
)}
</Box>
</AccordionSummary>
<AccordionDetails>
<Box>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{info.description}
</Typography>
{codeData ? (
<Stack spacing={1}>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<ContentCopy />}
onClick={() => handleCopyCode(codeData.code, role)}
disabled={isLoading}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Copy Code
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Email />}
onClick={() => handleOpenEmailDialog(role)}
disabled={isLoading}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Email Invite
</Button>
<Button
size="small"
variant="outlined"
startIcon={<Refresh />}
onClick={() => handleGenerateCode(role)}
disabled={isLoading}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Regenerate
</Button>
<IconButton
size="small"
color="error"
onClick={() => handleRevokeCode(role)}
disabled={isLoading}
>
<Delete fontSize="small" />
</IconButton>
</Box>
</Stack>
) : (
<Button
variant="contained"
color={info.color}
onClick={() => handleGenerateCode(role)}
disabled={isLoading}
startIcon={isLoading ? <CircularProgress size={16} /> : null}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
Generate {info.label} Invite Code
</Button>
)}
</Box>
</AccordionDetails>
</Accordion>
);
};
if (loading) {
return (
<Paper elevation={0} sx={{ p: 3, borderRadius: 3, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
</Paper>
);
}
return (
<>
<Paper elevation={0} sx={{ p: 3, borderRadius: 3, bgcolor: 'background.paper' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
<Typography variant="h6" fontWeight="600">
Role-Based Invites
</Typography>
<Tooltip title="Generate invite codes with specific roles and permissions">
<IconButton size="small">
<Info fontSize="small" />
</IconButton>
</Tooltip>
</Box>
<Alert severity="info" sx={{ mb: 3, borderRadius: 2 }}>
Create role-specific invite codes that automatically assign permissions when used.
</Alert>
<Box>
{renderRoleCodeCard('parent')}
{renderRoleCodeCard('caregiver')}
{renderRoleCodeCard('viewer')}
</Box>
</Paper>
{/* Email Invite Dialog */}
<Dialog
open={emailDialogOpen}
onClose={() => setEmailDialogOpen(false)}
maxWidth="sm"
fullWidth
>
<DialogTitle>
Send {selectedRole && roleInfo[selectedRole].icon} {selectedRole && roleInfo[selectedRole].label} Invite via Email
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Send an invitation email with the {selectedRole} invite code
</Typography>
<TextField
fullWidth
label="Recipient Email"
type="email"
value={recipientEmail}
onChange={(e) => setRecipientEmail(e.target.value)}
placeholder="name@example.com"
autoFocus
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEmailDialogOpen(false)} sx={{ textTransform: 'none' }}>
Cancel
</Button>
<Button
onClick={handleSendEmailInvite}
variant="contained"
disabled={!recipientEmail || emailSending}
startIcon={emailSending ? <CircularProgress size={16} /> : <Email />}
sx={{ textTransform: 'none' }}
>
Send Invite
</Button>
</DialogActions>
</Dialog>
</>
);
}