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
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:
@@ -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>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user