diff --git a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx
index dfbd557..2fd7980 100644
--- a/app/(tabs)/beneficiaries/[id]/equipment-status.tsx
+++ b/app/(tabs)/beneficiaries/[id]/equipment-status.tsx
@@ -8,6 +8,7 @@ import {
Pressable,
ActivityIndicator,
RefreshControl,
+ Alert,
} from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { SafeAreaView } from 'react-native-safe-area-context';
diff --git a/app/(tabs)/beneficiaries/[id]/equipment.tsx b/app/(tabs)/beneficiaries/[id]/equipment.tsx
index 25ab286..dc407c7 100644
--- a/app/(tabs)/beneficiaries/[id]/equipment.tsx
+++ b/app/(tabs)/beneficiaries/[id]/equipment.tsx
@@ -22,6 +22,7 @@ import {
Spacing,
Shadows,
} from '@/constants/theme';
+import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
interface Device {
id: string;
@@ -222,12 +223,12 @@ export default function EquipmentScreen() {
router.back()}>
- Equipment
+ Sensors
- Loading devices...
+ Loading sensors...
);
@@ -240,10 +241,17 @@ export default function EquipmentScreen() {
router.back()}>
- Equipment
-
-
-
+ Sensors
+
+
+
+
+
+
diff --git a/app/(tabs)/beneficiaries/[id]/share.tsx b/app/(tabs)/beneficiaries/[id]/share.tsx
index 3a48e2e..4ed616a 100644
--- a/app/(tabs)/beneficiaries/[id]/share.tsx
+++ b/app/(tabs)/beneficiaries/[id]/share.tsx
@@ -26,6 +26,7 @@ import {
FontWeights,
Spacing,
} from '@/constants/theme';
+import { BeneficiaryMenu } from '@/components/ui/BeneficiaryMenu';
type Role = 'caretaker' | 'guardian';
@@ -306,7 +307,11 @@ export default function ShareAccessScreen() {
Access
-
+
Subscription
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index f8f100b..9712bbe 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -37,6 +37,13 @@ interface BeneficiaryCardProps {
// Equipment status config
const equipmentStatusConfig = {
+ none: {
+ icon: 'cart-outline' as const,
+ label: 'Get kit',
+ sublabel: 'Order equipment',
+ color: AppColors.textMuted,
+ bgColor: AppColors.backgroundSecondary,
+ },
ordered: {
icon: 'cube-outline' as const,
label: 'Kit ordered',
@@ -65,33 +72,22 @@ const equipmentStatusConfig = {
color: AppColors.success,
bgColor: AppColors.successLight,
},
- demo: {
- icon: 'pulse-outline' as const,
- label: 'Monitoring',
- sublabel: 'Demo mode',
- color: AppColors.success,
- bgColor: AppColors.successLight,
- },
};
function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardProps) {
const equipmentStatus = beneficiary.equipmentStatus;
const isAwaitingEquipment = equipmentStatus && ['ordered', 'shipped', 'delivered'].includes(equipmentStatus);
- const isMonitoring = equipmentStatus && ['active', 'demo'].includes(equipmentStatus);
- const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : null;
+ const isEquipmentActive = equipmentStatus === 'active' || equipmentStatus === 'demo';
+ const hasSubscription = isSubscriptionActive(beneficiary.subscription);
+ const needsKit = !equipmentStatus || equipmentStatus === 'none';
- // Check if has devices/equipment connected
- const hasDevices = beneficiary.hasDevices ||
- (beneficiary.devices && beneficiary.devices.length > 0) ||
- beneficiary.device_id;
+ // Monitoring = equipment works + subscription active (all good!)
+ const isMonitoring = isEquipmentActive && hasSubscription;
- // Show subscription warning if:
- // 1. Has devices connected (equipment is installed)
- // 2. NOT awaiting equipment delivery
- // 3. Subscription is missing or not active
- const hasNoSubscription = hasDevices && !isAwaitingEquipment && (
- !isSubscriptionActive(beneficiary.subscription)
- );
+ // No subscription warning: equipment works but no subscription
+ const hasNoSubscription = isEquipmentActive && !hasSubscription;
+
+ const statusConfig = equipmentStatus ? equipmentStatusConfig[equipmentStatus as keyof typeof equipmentStatusConfig] : equipmentStatusConfig.none;
// Check if avatar is valid (not empty, null, or placeholder)
const hasValidAvatar = beneficiary.avatar &&
@@ -129,6 +125,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
{/* Name and Status */}
{beneficiary.name}
+ {/* User's role for this beneficiary */}
+ {beneficiary.role && (
+
+ You: {beneficiary.role.charAt(0).toUpperCase() + beneficiary.role.slice(1)}
+
+ )}
{/* Equipment status badge (awaiting delivery) */}
{isAwaitingEquipment && statusConfig && (
@@ -138,12 +140,12 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
)}
- {/* Monitoring badge (active/demo) */}
- {isMonitoring && statusConfig && (
-
-
-
- {statusConfig.label}
+ {/* Monitoring badge (equipment works + subscription active) */}
+ {isMonitoring && (
+
+
+
+ {equipmentStatusConfig.active.label}
)}
@@ -154,6 +156,15 @@ function BeneficiaryCard({ beneficiary, onPress, onActivate }: BeneficiaryCardPr
No subscription
)}
+ {/* Get kit badge (no equipment) */}
+ {needsKit && (
+
+
+
+ {equipmentStatusConfig.none.label}
+
+
+ )}
{/* Action button or Arrow */}
@@ -578,6 +589,11 @@ const styles = StyleSheet.create({
fontWeight: FontWeights.semibold,
color: AppColors.textPrimary,
},
+ roleText: {
+ fontSize: FontSizes.xs,
+ color: AppColors.textMuted,
+ marginTop: 2,
+ },
noSubscriptionBadge: {
flexDirection: 'row',
alignItems: 'center',
diff --git a/backend/src/index.js b/backend/src/index.js
index e45ace5..47483c4 100644
--- a/backend/src/index.js
+++ b/backend/src/index.js
@@ -91,8 +91,9 @@ app.use('/function/well-api/api', authLimiter); // Legacy API с auth
app.use('/api/webhook/stripe', express.raw({ type: 'application/json' }));
// JSON body parser for other routes
-app.use(express.json());
-app.use(express.urlencoded({ extended: true }));
+// Increased limit for base64 avatar uploads
+app.use(express.json({ limit: '10mb' }));
+app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// ============ ROUTES ============
diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js
index 10339f1..d584e0e 100644
--- a/backend/src/routes/auth.js
+++ b/backend/src/routes/auth.js
@@ -4,6 +4,7 @@ const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const { supabase } = require('../config/supabase');
const { sendOTPEmail } = require('../services/email');
+const storage = require('../services/storage');
/**
* POST /api/auth/check-email
@@ -233,7 +234,8 @@ router.post('/verify-otp', async (req, res) => {
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
- role: user.role || 'user'
+ role: user.role || 'user',
+ avatarUrl: user.avatar_url || null
},
beneficiaries
});
@@ -312,7 +314,8 @@ router.get('/me', async (req, res) => {
firstName: user.first_name,
lastName: user.last_name,
phone: user.phone,
- role: user.role || 'user'
+ role: user.role || 'user',
+ avatarUrl: user.avatar_url || null
},
beneficiaries
});
@@ -385,4 +388,108 @@ router.patch('/profile', async (req, res) => {
}
});
+/**
+ * PATCH /api/auth/avatar
+ * Upload/update user profile avatar
+ * - Uploads to MinIO if configured
+ * - Falls back to base64 in DB if MinIO not available
+ */
+router.patch('/avatar', async (req, res) => {
+ try {
+ const authHeader = req.headers.authorization;
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
+ return res.status(401).json({ error: 'No token provided' });
+ }
+
+ const token = authHeader.split(' ')[1];
+ const decoded = jwt.verify(token, process.env.JWT_SECRET);
+ const userId = decoded.userId;
+
+ const { avatar } = req.body; // base64 string or null to remove
+
+ console.log('[AUTH] Avatar update:', { userId, hasAvatar: !!avatar });
+
+ // Validate base64 if provided
+ if (avatar && !avatar.startsWith('data:image/')) {
+ return res.status(400).json({ error: 'Invalid image format. Must be base64 data URI' });
+ }
+
+ let avatarUrl = null;
+
+ if (avatar) {
+ // Try to upload to MinIO
+ if (storage.isConfigured()) {
+ try {
+ // Get current avatar to delete old file
+ const { data: current } = await supabase
+ .from('users')
+ .select('avatar_url')
+ .eq('id', userId)
+ .single();
+
+ // Delete old avatar from MinIO if exists
+ if (current?.avatar_url && current.avatar_url.includes('minio')) {
+ const oldKey = storage.extractKeyFromUrl(current.avatar_url);
+ if (oldKey) {
+ try {
+ await storage.deleteFile(oldKey);
+ } catch (e) {
+ console.warn('[AUTH] Failed to delete old avatar:', e.message);
+ }
+ }
+ }
+
+ // Upload new avatar to MinIO
+ const filename = `user-${userId}-${Date.now()}`;
+ const result = await storage.uploadBase64Image(avatar, 'avatars/users', filename);
+ avatarUrl = result.url;
+
+ console.log('[AUTH] Avatar uploaded to MinIO:', avatarUrl);
+ } catch (uploadError) {
+ console.error('[AUTH] MinIO upload failed, falling back to DB:', uploadError.message);
+ // Fallback: store base64 in DB
+ avatarUrl = avatar;
+ }
+ } else {
+ // MinIO not configured - store base64 in DB
+ console.log('[AUTH] MinIO not configured, storing base64 in DB');
+ avatarUrl = avatar;
+ }
+ }
+
+ // Update avatar_url in users table
+ const { data: user, error } = await supabase
+ .from('users')
+ .update({
+ avatar_url: avatarUrl,
+ updated_at: new Date().toISOString()
+ })
+ .eq('id', userId)
+ .select('id, email, first_name, last_name, avatar_url')
+ .single();
+
+ if (error) {
+ console.error('[AUTH] Avatar update error:', error);
+ return res.status(500).json({ error: 'Failed to update avatar' });
+ }
+
+ console.log('[AUTH] Avatar updated:', { userId, avatarUrl: user.avatar_url?.substring(0, 50) });
+
+ res.json({
+ success: true,
+ user: {
+ id: user.id,
+ email: user.email,
+ firstName: user.first_name,
+ lastName: user.last_name,
+ avatarUrl: user.avatar_url
+ }
+ });
+
+ } catch (error) {
+ console.error('[AUTH] Avatar error:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
module.exports = router;
diff --git a/components/ui/BeneficiaryMenu.tsx b/components/ui/BeneficiaryMenu.tsx
index 185dd38..d9451bb 100644
--- a/components/ui/BeneficiaryMenu.tsx
+++ b/components/ui/BeneficiaryMenu.tsx
@@ -4,7 +4,8 @@ import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme';
-export type MenuItemId = 'edit' | 'access' | 'subscription' | 'equipment' | 'remove';
+export type MenuItemId = 'dashboard' | 'edit' | 'access' | 'subscription' | 'sensors' | 'remove';
+export type UserRole = 'custodian' | 'guardian' | 'caretaker';
interface MenuItem {
id: MenuItemId;
@@ -13,16 +14,29 @@ interface MenuItem {
danger?: boolean;
}
+// Permissions by role (Variant B)
+// Custodian: all permissions
+// Guardian: all except remove
+// Caretaker: dashboard, edit, sensors only
+const ROLE_PERMISSIONS: Record = {
+ 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: 'equipment', icon: 'hardware-chip-outline', label: 'Equipment' },
+ { 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) */
@@ -35,6 +49,7 @@ interface BeneficiaryMenuProps {
export function BeneficiaryMenu({
beneficiaryId,
+ userRole = 'caretaker', // Default to minimum permissions if not specified (security-first approach)
visibleItems,
currentPage,
onEdit,
@@ -46,6 +61,9 @@ export function BeneficiaryMenu({
setIsVisible(false);
switch (itemId) {
+ case 'dashboard':
+ router.push(`/(tabs)/beneficiaries/${beneficiaryId}`);
+ break;
case 'edit':
if (onEdit) {
onEdit();
@@ -60,7 +78,7 @@ export function BeneficiaryMenu({
case 'subscription':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/subscription`);
break;
- case 'equipment':
+ case 'sensors':
router.push(`/(tabs)/beneficiaries/${beneficiaryId}/equipment`);
break;
case 'remove':
@@ -74,13 +92,20 @@ export function BeneficiaryMenu({
}
};
- // Filter menu items - only hide current page
- let menuItems = ALL_MENU_ITEMS;
+ // 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 = ALL_MENU_ITEMS.filter(item => visibleItems.includes(item.id));
- } else if (currentPage) {
- menuItems = ALL_MENU_ITEMS.filter(item => item.id !== currentPage);
+ menuItems = menuItems.filter(item => visibleItems.includes(item.id));
+ }
+
+ if (currentPage) {
+ menuItems = menuItems.filter(item => item.id !== currentPage);
}
return (
diff --git a/services/api.ts b/services/api.ts
index 8a759b6..50659d3 100644
--- a/services/api.ts
+++ b/services/api.ts
@@ -1,6 +1,6 @@
import type { ApiError, ApiResponse, AuthResponse, Beneficiary, BeneficiaryDashboardData, ChatResponse, DashboardSingleResponse, NotificationSettings } from '@/types';
import * as Crypto from 'expo-crypto';
-import * as FileSystem from 'expo-file-system';
+import { File } from 'expo-file-system';
import * as SecureStore from 'expo-secure-store';
// Callback for handling unauthorized responses (401)
@@ -344,6 +344,7 @@ class ApiService {
// /auth/me returns { user: {...}, beneficiaries: [...] }
// Extract user data from nested 'user' object
const userData = profile.data.user || profile.data;
+ console.log('[API] getStoredUser: userData =', JSON.stringify(userData));
return {
user_id: userData.id,
@@ -460,6 +461,51 @@ class ApiService {
}
}
+ // Update user profile avatar on WellNuo API
+ async updateProfileAvatar(imageUri: string | null): Promise> {
+ const token = await this.getToken();
+
+ if (!token) {
+ return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
+ }
+
+ try {
+ let avatarData: string | null = null;
+
+ if (imageUri) {
+ // Read image as base64 using new expo-file-system v19+ File API
+ const file = new File(imageUri);
+ const base64Data = await file.base64();
+
+ // Determine MIME type from URI
+ const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';
+ const mimeType = extension === 'png' ? 'image/png' : 'image/jpeg';
+
+ avatarData = `data:${mimeType};base64,${base64Data}`;
+ }
+
+ const response = await fetch(`${WELLNUO_API_URL}/auth/avatar`, {
+ method: 'PATCH',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ avatar: avatarData }),
+ });
+
+ const data = await response.json();
+
+ if (!response.ok) {
+ return { ok: false, error: { message: data.error || 'Failed to update avatar' } };
+ }
+
+ return { data: data.user, ok: true };
+ } catch (error) {
+ console.error('[API] updateProfileAvatar error:', error);
+ return { ok: false, error: { message: 'Network error', code: 'NETWORK_ERROR' } };
+ }
+ }
+
// Beneficiaries (elderly people being monitored)
async getBeneficiaries(): Promise> {
const token = await this.getToken();
@@ -619,6 +665,7 @@ class ApiService {
equipmentStatus: item.equipmentStatus,
hasDevices: item.hasDevices || false,
trackingNumber: item.trackingNumber,
+ role: item.role, // User's role for this beneficiary (custodian, guardian, caretaker)
}));
return { data: beneficiaries, ok: true };
@@ -675,6 +722,8 @@ class ApiService {
equipmentStatus: data.equipmentStatus,
hasDevices: data.hasDevices || false,
trackingNumber: data.trackingNumber,
+ // User's role for this beneficiary (custodian, guardian, caretaker)
+ role: data.role,
};
return { data: beneficiary, ok: true };
@@ -776,10 +825,9 @@ class ApiService {
let base64Image: string | null = null;
if (imageUri) {
- // Read file as base64 using stable FileSystem API
- const base64Data = await FileSystem.readAsStringAsync(imageUri, {
- encoding: FileSystem.EncodingType.Base64,
- });
+ // Read file as base64 using new expo-file-system v19+ File API
+ const file = new File(imageUri);
+ const base64Data = await file.base64();
// Determine mime type from URI extension
const extension = imageUri.split('.').pop()?.toLowerCase() || 'jpeg';