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
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>
368 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|