WellNuo/components/ui/BeneficiaryMenu.tsx
Sergei 521ff52344 Add comprehensive testing and documentation for role-based UI permissions
This commit implements role-based permission testing and documentation for
the beneficiary management system.

The role-based UI was already correctly implemented in BeneficiaryMenu.tsx
(lines 21-25). This commit adds:

- Comprehensive test suite for BeneficiaryMenu role permissions
- Test suite for role-based edit modal functionality
- Detailed documentation in docs/ROLE_BASED_PERMISSIONS.md
- Jest configuration for future testing
- testID added to menu button for testing accessibility

Role Permission Summary:
- Custodian: Full access (all features including remove)
- Guardian: Most features (cannot remove beneficiary)
- Caretaker: Limited access (dashboard, edit nickname, sensors only)

Edit Functionality:
- Custodians can edit full profile (name, address, avatar)
- Guardians/Caretakers can only edit personal nickname (customName)
- Backend validates all permissions server-side for security

Tests verify:
 Menu items filtered correctly by role
 Custodian has full edit capabilities
 Guardian/Caretaker limited to nickname editing only
 Default role is caretaker (security-first approach)
 Navigation routes work correctly

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

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-29 11:39:18 -08:00

213 lines
6.3 KiB
TypeScript

import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, Pressable } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
export type MenuItemId = 'dashboard' | 'edit' | 'access' | 'subscription' | 'sensors' | 'remove';
export type UserRole = 'custodian' | 'guardian' | 'caretaker';
interface MenuItem {
id: MenuItemId;
icon: keyof typeof Ionicons.glyphMap;
label: string;
danger?: boolean;
}
// Permissions by role (Variant B)
// Custodian: all permissions
// Guardian: all except remove
// Caretaker: dashboard, edit, sensors only
const ROLE_PERMISSIONS: Record<UserRole, MenuItemId[]> = {
custodian: ['dashboard', 'edit', 'access', 'subscription', 'sensors', 'remove'],
guardian: ['dashboard', 'edit', 'access', 'subscription', 'sensors'],
caretaker: ['dashboard', 'edit', 'sensors'],
};
const ALL_MENU_ITEMS: MenuItem[] = [
{ id: 'dashboard', icon: 'grid-outline', label: 'Dashboard' },
{ id: 'edit', icon: 'create-outline', label: 'Edit' },
{ id: 'access', icon: 'share-outline', label: 'Access' },
{ id: 'subscription', icon: 'diamond-outline', label: 'Subscription' },
{ id: 'sensors', icon: 'hardware-chip-outline', label: 'Sensors' },
{ id: 'remove', icon: 'trash-outline', label: 'Remove', danger: true },
];
interface BeneficiaryMenuProps {
beneficiaryId: string | number;
/** User's role for this beneficiary - determines available menu items */
userRole?: UserRole;
/** Which menu items to show. If not provided, shows all except current page */
visibleItems?: MenuItemId[];
/** Which menu item represents the current page (will be hidden) */
currentPage?: MenuItemId;
/** Custom handler for Edit action */
onEdit?: () => void;
/** Custom handler for Remove action */
onRemove?: () => void;
}
export function BeneficiaryMenu({
beneficiaryId,
userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach)
visibleItems,
currentPage,
onEdit,
onRemove,
}: BeneficiaryMenuProps) {
const [isVisible, setIsVisible] = useState(false);
const handleMenuAction = (itemId: MenuItemId) => {
setIsVisible(false);
switch (itemId) {
case 'dashboard':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
break;
case 'edit':
if (onEdit) {
onEdit();
} else {
// Navigate to main page with edit=true param to open edit modal
router.push(`/(tabs)/beneficiaries/${beneficiaryId}?edit=true`);
}
break;
case 'access':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/share`);
break;
case 'subscription':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
break;
case 'sensors':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
break;
case 'remove':
if (onRemove) {
onRemove();
} else {
// Navigate to main page
router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
}
break;
}
};
// Filter menu items based on:
// 1. User role permissions
// 2. Explicitly visible items (if provided)
// 3. Current page (hide it from menu)
const allowedByRole = ROLE_PERMISSIONS[userRole] || ROLE_PERMISSIONS.caretaker;
let menuItems = ALL_MENU_ITEMS.filter(item => allowedByRole.includes(item.id));
if (visibleItems) {
menuItems = menuItems.filter(item => visibleItems.includes(item.id));
}
if (currentPage) {
menuItems = menuItems.filter(item => item.id !== currentPage);
}
return (
<View>
<TouchableOpacity
style={styles.menuButton}
onPress={() => setIsVisible(!isVisible)}
testID="menu-button"
>
<Ionicons name="ellipsis-vertical" size={22} color={AppColors.textPrimary} />
</TouchableOpacity>
<Modal
visible={isVisible}
transparent={true}
animationType="fade"
onRequestClose={() => setIsVisible(false)}
>
{/* Full screen backdrop */}
<Pressable
style={styles.modalBackdrop}
onPress={() => setIsVisible(false)}
>
{/* Menu positioned at top right */}
<Pressable
style={styles.dropdownMenuContainer}
onPress={(e) => e.stopPropagation()}
>
<View style={styles.dropdownMenu}>
{menuItems.map((item) => (
<TouchableOpacity
key={item.id}
style={[
styles.dropdownItem,
item.danger && styles.dropdownItemDanger,
]}
onPress={() => handleMenuAction(item.id)}
>
<Ionicons
name={item.icon}
size={20}
color={item.danger ? AppColors.error : AppColors.textPrimary}
/>
<Text
style={[
styles.dropdownItemText,
item.danger && styles.dropdownItemTextDanger,
]}
>
{item.label}
</Text>
</TouchableOpacity>
))}
</View>
</Pressable>
</Pressable>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
menuButton: {
width: 32,
height: 32,
alignItems: 'center',
justifyContent: 'center',
},
modalBackdrop: {
flex: 1,
backgroundColor: 'transparent',
},
dropdownMenuContainer: {
position: 'absolute',
top: 100, // Below status bar and header
right: Spacing.md,
},
dropdownMenu: {
backgroundColor: AppColors.surface,
borderRadius: BorderRadius.lg,
minWidth: 180,
overflow: 'hidden',
...Shadows.lg,
},
dropdownItem: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: Spacing.md,
paddingHorizontal: Spacing.md,
gap: Spacing.sm,
width: '100%',
},
dropdownItemText: {
fontSize: FontSizes.base,
color: AppColors.textPrimary,
},
dropdownItemDanger: {
borderTopWidth: 1,
borderTopColor: AppColors.border,
},
dropdownItemTextDanger: {
color: AppColors.error,
},
});