fix: Enforce role-based permissions in frontend
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 / Build Application (push) Has been cancelled
CI/CD Pipeline / Lint and Test (push) Has been cancelled
CI/CD Pipeline / E2E Tests (push) Has been cancelled

Fixed critical permission bypass where viewers could:
- Remove family members (now only parents can)
- Invite new members (now only parents can)
- Generate share codes (now only parents can)
- Add children (now only parents can)
- Edit children (now only parents and caregivers can)
- Delete children (now only parents can)

Changes:
- Family page: Added isParent checks for all admin actions
- Children page: Added canAddChildren, canEditChildren, canDeleteChildren checks
- Both pages now use useSelectedFamily hook for consistent role access

Backend already had correct permission checks - this fixes the frontend to respect them.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Andrei
2025-10-09 12:59:33 +00:00
parent ce939b909b
commit 2be0e90f19
2 changed files with 77 additions and 56 deletions

View File

@@ -26,11 +26,13 @@ import { DeleteConfirmDialog } from '@/components/children/DeleteConfirmDialog';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { useTranslation } from '@/hooks/useTranslation'; import { useTranslation } from '@/hooks/useTranslation';
import { useLocalizedDate } from '@/hooks/useLocalizedDate'; import { useLocalizedDate } from '@/hooks/useLocalizedDate';
import { useSelectedFamily } from '@/hooks/useSelectedFamily';
export default function ChildrenPage() { export default function ChildrenPage() {
const { t } = useTranslation('children'); const { t } = useTranslation('children');
const { user } = useAuth(); const { user } = useAuth();
const { format: formatDate } = useLocalizedDate(); const { format: formatDate } = useLocalizedDate();
const { familyId, familyRole } = useSelectedFamily();
const [children, setChildren] = useState<Child[]>([]); const [children, setChildren] = useState<Child[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string>(''); const [error, setError] = useState<string>('');
@@ -40,8 +42,10 @@ export default function ChildrenPage() {
const [childToDelete, setChildToDelete] = useState<Child | null>(null); const [childToDelete, setChildToDelete] = useState<Child | null>(null);
const [actionLoading, setActionLoading] = useState(false); const [actionLoading, setActionLoading] = useState(false);
// Get familyId from user // Permission checks based on role
const familyId = user?.families?.[0]?.familyId; const canAddChildren = familyRole === 'parent';
const canEditChildren = familyRole === 'parent' || familyRole === 'caregiver';
const canDeleteChildren = familyRole === 'parent';
useEffect(() => { useEffect(() => {
if (familyId) { if (familyId) {
@@ -164,19 +168,21 @@ export default function ChildrenPage() {
{t('subtitle')} {t('subtitle')}
</Typography> </Typography>
</Box> </Box>
<Button {canAddChildren && (
variant="contained" <Button
startIcon={<Add />} variant="contained"
onClick={handleAddChild} startIcon={<Add />}
disabled={loading || !familyId} onClick={handleAddChild}
sx={{ disabled={loading || !familyId}
borderRadius: 2, sx={{
textTransform: 'none', borderRadius: 2,
px: 3 textTransform: 'none',
}} px: 3
> }}
{t('addChild')} >
</Button> {t('addChild')}
</Button>
)}
</Box> </Box>
{error && ( {error && (
@@ -201,14 +207,16 @@ export default function ChildrenPage() {
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{t('noChildrenSubtitle')} {t('noChildrenSubtitle')}
</Typography> </Typography>
<Button {canAddChildren && (
variant="contained" <Button
startIcon={<Add />} variant="contained"
onClick={handleAddChild} startIcon={<Add />}
disabled={!familyId} onClick={handleAddChild}
> disabled={!familyId}
{t('addFirstChild')} >
</Button> {t('addFirstChild')}
</Button>
)}
</CardContent> </CardContent>
</Card> </Card>
</Grid> </Grid>
@@ -263,12 +271,16 @@ export default function ChildrenPage() {
</Box> </Box>
<Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: { xs: 'center', sm: 'flex-start' } }}> <Box sx={{ display: 'flex', gap: 1, mt: 2, justifyContent: { xs: 'center', sm: 'flex-start' } }}>
<IconButton size="medium" color="primary" onClick={() => handleEditChild(child)} sx={{ minWidth: 48, minHeight: 48 }}> {canEditChildren && (
<Edit /> <IconButton size="medium" color="primary" onClick={() => handleEditChild(child)} sx={{ minWidth: 48, minHeight: 48 }}>
</IconButton> <Edit />
<IconButton size="medium" color="error" onClick={() => handleDeleteClick(child)} sx={{ minWidth: 48, minHeight: 48 }}> </IconButton>
<Delete /> )}
</IconButton> {canDeleteChildren && (
<IconButton size="medium" color="error" onClick={() => handleDeleteClick(child)} sx={{ minWidth: 48, minHeight: 48 }}>
<Delete />
</IconButton>
)}
</Box> </Box>
</Paper> </Paper>
</motion.div> </motion.div>

View File

@@ -41,7 +41,7 @@ import { useSelectedFamily } from '@/hooks/useSelectedFamily';
export default function FamilyPage() { export default function FamilyPage() {
const { t } = useTranslation('family'); const { t } = useTranslation('family');
const { user, refreshUser } = useAuth(); const { user, refreshUser } = useAuth();
const { familyId, selectedIndex, setSelectedIndex, userFamilies, hasMultipleFamilies } = useSelectedFamily(); const { familyId, familyRole, selectedIndex, setSelectedIndex, userFamilies, hasMultipleFamilies } = useSelectedFamily();
const [family, setFamily] = useState<Family | null>(null); const [family, setFamily] = useState<Family | null>(null);
const [members, setMembers] = useState<FamilyMember[]>([]); const [members, setMembers] = useState<FamilyMember[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -54,6 +54,9 @@ export default function FamilyPage() {
const [snackbar, setSnackbar] = useState({ open: false, message: '' }); const [snackbar, setSnackbar] = useState({ open: false, message: '' });
const [familyNames, setFamilyNames] = useState<Record<string, string>>({}); const [familyNames, setFamilyNames] = useState<Record<string, string>>({});
// Check if current user is a parent (has admin permissions)
const isParent = familyRole === 'parent';
useEffect(() => { useEffect(() => {
if (familyId) { if (familyId) {
fetchFamilyData(); fetchFamilyData();
@@ -229,15 +232,17 @@ export default function FamilyPage() {
> >
{t('buttons.joinFamily')} {t('buttons.joinFamily')}
</Button> </Button>
<Button {isParent && (
variant="contained" <Button
startIcon={<PersonAdd />} variant="contained"
onClick={() => setInviteDialogOpen(true)} startIcon={<PersonAdd />}
disabled={loading || !familyId} onClick={() => setInviteDialogOpen(true)}
sx={{ borderRadius: 2, textTransform: 'none' }} disabled={loading || !familyId}
> sx={{ borderRadius: 2, textTransform: 'none' }}
{t('buttons.inviteMember')} >
</Button> {t('buttons.inviteMember')}
</Button>
)}
</Box> </Box>
</Box> </Box>
@@ -318,15 +323,17 @@ export default function FamilyPage() {
{t('buttons.copyCode')} {t('buttons.copyCode')}
</Button> </Button>
</Box> </Box>
<Button {isParent && (
variant="text" <Button
size="small" variant="text"
onClick={handleGenerateShareCode} size="small"
disabled={actionLoading} onClick={handleGenerateShareCode}
sx={{ borderRadius: 2, textTransform: 'none' }} disabled={actionLoading}
> sx={{ borderRadius: 2, textTransform: 'none' }}
Generate New Code >
</Button> Generate New Code
</Button>
)}
</Paper> </Paper>
</Grid> </Grid>
)} )}
@@ -347,13 +354,15 @@ export default function FamilyPage() {
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{t('members.noMembersDescription')} {t('members.noMembersDescription')}
</Typography> </Typography>
<Button {isParent && (
variant="outlined" <Button
startIcon={<PersonAdd />} variant="outlined"
onClick={() => setInviteDialogOpen(true)} startIcon={<PersonAdd />}
> onClick={() => setInviteDialogOpen(true)}
{t('buttons.inviteFirstMember')} >
</Button> {t('buttons.inviteFirstMember')}
</Button>
)}
</Box> </Box>
) : ( ) : (
<List sx={{ p: 0 }}> <List sx={{ p: 0 }}>
@@ -400,7 +409,7 @@ export default function FamilyPage() {
color={getRoleColor(member.role)} color={getRoleColor(member.role)}
sx={{ borderRadius: 1, mr: 1 }} sx={{ borderRadius: 1, mr: 1 }}
/> />
{!isCurrentUser(member.userId) && ( {isParent && !isCurrentUser(member.userId) && (
<IconButton <IconButton
size="small" size="small"
onClick={() => handleRemoveClick(member)} onClick={() => handleRemoveClick(member)}