Replace Alert with Toast for invite code copy, rename Share to Access
This commit is contained in:
parent
ad35dac850
commit
f6a2d5e687
@ -14,10 +14,8 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Animated,
|
Animated,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Switch,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { WebView } from 'react-native-webview';
|
import { WebView } from 'react-native-webview';
|
||||||
import * as SecureStore from 'expo-secure-store';
|
|
||||||
import { useLocalSearchParams, router } from 'expo-router';
|
import { useLocalSearchParams, router } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
@ -31,6 +29,7 @@ import { FullScreenError } from '@/components/ui/ErrorMessage';
|
|||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
|
import { SubscriptionPayment } from '@/components/SubscriptionPayment';
|
||||||
import { useToast } from '@/components/ui/Toast';
|
import { useToast } from '@/components/ui/Toast';
|
||||||
|
import { DevModeToggle } from '@/components/ui/DevModeToggle';
|
||||||
import MockDashboard from '@/components/MockDashboard';
|
import MockDashboard from '@/components/MockDashboard';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
@ -57,7 +56,9 @@ const STRIPE_API_URL = 'https://wellnuo.smartlaunchhub.com/api/stripe';
|
|||||||
|
|
||||||
// WebView Dashboard URL - uses test NDK account for demo data
|
// WebView Dashboard URL - uses test NDK account for demo data
|
||||||
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
||||||
const TEST_NDK_DEPLOYMENT_ID = '1'; // anandk test deployment with real sensor data
|
// Test credentials for WebView - anandk account has real sensor data
|
||||||
|
const TEST_NDK_USER = 'anandk';
|
||||||
|
const TEST_NDK_PASSWORD = 'anandk_8';
|
||||||
|
|
||||||
// Starter Kit info
|
// Starter Kit info
|
||||||
const STARTER_KIT = {
|
const STARTER_KIT = {
|
||||||
@ -472,21 +473,25 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
loadBeneficiary();
|
loadBeneficiary();
|
||||||
}, [loadBeneficiary]);
|
}, [loadBeneficiary]);
|
||||||
|
|
||||||
// Load credentials for WebView
|
// Load test credentials for WebView (anandk account has sensor data)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCredentials = async () => {
|
const loadTestCredentials = async () => {
|
||||||
try {
|
try {
|
||||||
const token = await SecureStore.getItemAsync('accessToken');
|
// Always use anandk test account for WebView dashboard
|
||||||
const user = await SecureStore.getItemAsync('userName');
|
const response = await api.login(TEST_NDK_USER, TEST_NDK_PASSWORD);
|
||||||
const uid = await SecureStore.getItemAsync('userId');
|
if (response.ok && response.data) {
|
||||||
setAuthToken(token);
|
setAuthToken(response.data.token);
|
||||||
setUserName(user);
|
setUserName(TEST_NDK_USER);
|
||||||
setUserId(uid);
|
setUserId(response.data.user_id?.toString() || null);
|
||||||
|
console.log('[WebView] Loaded test credentials for anandk');
|
||||||
|
} else {
|
||||||
|
console.error('[WebView] Failed to get test token:', response.error);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load credentials:', err);
|
console.error('[WebView] Failed to load test credentials:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadCredentials();
|
loadTestCredentials();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Sync beneficiary data when localBeneficiaries changes (especially after avatar update)
|
// Sync beneficiary data when localBeneficiaries changes (especially after avatar update)
|
||||||
@ -675,7 +680,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusColor = beneficiary.status === 'online' ? AppColors.online : AppColors.offline;
|
|
||||||
|
|
||||||
// Render based on setup state
|
// Render based on setup state
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
@ -711,14 +715,19 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
case 'ready':
|
case 'ready':
|
||||||
default:
|
default:
|
||||||
// WebView mode - uses test NDK deployment for demo data
|
// WebView mode - uses test anandk account for all beneficiaries
|
||||||
if (showWebView) {
|
if (showWebView) {
|
||||||
const webViewUrl = `${DASHBOARD_URL}?deployment_id=${TEST_NDK_DEPLOYMENT_ID}`;
|
// Token is injected via injectedJavaScript, no need for URL params
|
||||||
return (
|
return (
|
||||||
<View style={styles.webViewContainer}>
|
<View style={styles.webViewContainer}>
|
||||||
|
{/* Developer Toggle - at top */}
|
||||||
|
<View style={styles.webViewToggleTop}>
|
||||||
|
<DevModeToggle value={showWebView} onValueChange={setShowWebView} />
|
||||||
|
</View>
|
||||||
|
{/* WebView below */}
|
||||||
<WebView
|
<WebView
|
||||||
ref={webViewRef}
|
ref={webViewRef}
|
||||||
source={{ uri: webViewUrl }}
|
source={{ uri: DASHBOARD_URL }}
|
||||||
style={styles.webView}
|
style={styles.webView}
|
||||||
javaScriptEnabled={true}
|
javaScriptEnabled={true}
|
||||||
domStorageEnabled={true}
|
domStorageEnabled={true}
|
||||||
@ -733,24 +742,6 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* Developer Toggle - always visible to switch back */}
|
|
||||||
<View style={styles.webViewToggleOverlay}>
|
|
||||||
<View style={styles.devToggleCard}>
|
|
||||||
<View style={styles.devToggleLeft}>
|
|
||||||
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
|
|
||||||
<View>
|
|
||||||
<Text style={styles.devToggleLabel}>Developer Mode</Text>
|
|
||||||
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={showWebView}
|
|
||||||
onValueChange={setShowWebView}
|
|
||||||
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
|
|
||||||
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -770,20 +761,8 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{/* Developer Toggle for WebView */}
|
{/* Developer Toggle for WebView */}
|
||||||
<View style={styles.devToggleCard}>
|
<View style={styles.devToggleWrapper}>
|
||||||
<View style={styles.devToggleLeft}>
|
<DevModeToggle value={showWebView} onValueChange={setShowWebView} />
|
||||||
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
|
|
||||||
<View>
|
|
||||||
<Text style={styles.devToggleLabel}>Developer Mode</Text>
|
|
||||||
<Text style={styles.devToggleHint}>Show WebView dashboard</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Switch
|
|
||||||
value={showWebView}
|
|
||||||
onValueChange={setShowWebView}
|
|
||||||
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
|
|
||||||
thumbColor={showWebView ? AppColors.primary : AppColors.textMuted}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Activity Dashboard */}
|
{/* Activity Dashboard */}
|
||||||
@ -803,18 +782,15 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
|
|
||||||
{/* Avatar + Name */}
|
{/* Avatar + Name */}
|
||||||
<View style={styles.headerCenter}>
|
<View style={styles.headerCenter}>
|
||||||
<View style={styles.headerAvatarWrapper}>
|
{beneficiary.avatar ? (
|
||||||
{beneficiary.avatar ? (
|
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
||||||
<Image source={{ uri: beneficiary.avatar }} style={styles.headerAvatarImage} />
|
) : (
|
||||||
) : (
|
<View style={styles.headerAvatar}>
|
||||||
<View style={styles.headerAvatar}>
|
<Text style={styles.headerAvatarText}>
|
||||||
<Text style={styles.headerAvatarText}>
|
{beneficiary.name.charAt(0).toUpperCase()}
|
||||||
{beneficiary.name.charAt(0).toUpperCase()}
|
</Text>
|
||||||
</Text>
|
</View>
|
||||||
</View>
|
)}
|
||||||
)}
|
|
||||||
<View style={[styles.headerStatusDot, { backgroundColor: statusColor }]} />
|
|
||||||
</View>
|
|
||||||
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
<Text style={styles.headerTitle}>{beneficiary.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@ -845,7 +821,7 @@ export default function BeneficiaryDetailScreen() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
|
<Ionicons name="share-outline" size={20} color={AppColors.textPrimary} />
|
||||||
<Text style={styles.dropdownItemText}>Share</Text>
|
<Text style={styles.dropdownItemText}>Access</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@ -1071,41 +1047,19 @@ const styles = StyleSheet.create({
|
|||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: AppColors.background,
|
borderColor: AppColors.background,
|
||||||
},
|
},
|
||||||
// Developer Toggle
|
// Developer Toggle Wrapper
|
||||||
devToggleCard: {
|
devToggleWrapper: {
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
backgroundColor: AppColors.warningLight,
|
|
||||||
borderRadius: BorderRadius.lg,
|
|
||||||
padding: Spacing.md,
|
|
||||||
marginBottom: Spacing.lg,
|
marginBottom: Spacing.lg,
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: AppColors.warning,
|
|
||||||
},
|
|
||||||
devToggleLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: Spacing.md,
|
|
||||||
},
|
|
||||||
devToggleLabel: {
|
|
||||||
fontSize: FontSizes.sm,
|
|
||||||
fontWeight: FontWeights.semibold,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
},
|
|
||||||
devToggleHint: {
|
|
||||||
fontSize: FontSizes.xs,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
},
|
},
|
||||||
// WebView
|
// WebView
|
||||||
webViewContainer: {
|
webViewContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
webViewToggleOverlay: {
|
webViewToggleTop: {
|
||||||
position: 'absolute',
|
paddingHorizontal: Spacing.md,
|
||||||
bottom: 100, // Above tab bar
|
paddingTop: Spacing.md,
|
||||||
left: Spacing.md,
|
paddingBottom: Spacing.sm,
|
||||||
right: Spacing.md,
|
backgroundColor: AppColors.background,
|
||||||
},
|
},
|
||||||
webView: {
|
webView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@ -17,6 +17,7 @@ import { SafeAreaView } from 'react-native-safe-area-context';
|
|||||||
import { router, useLocalSearchParams } from 'expo-router';
|
import { router, useLocalSearchParams } from 'expo-router';
|
||||||
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
import { useBeneficiary } from '@/contexts/BeneficiaryContext';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import { api } from '@/services/api';
|
import { api } from '@/services/api';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
@ -28,6 +29,12 @@ import {
|
|||||||
|
|
||||||
type Role = 'caretaker' | 'guardian';
|
type Role = 'caretaker' | 'guardian';
|
||||||
|
|
||||||
|
// Local beneficiaries have timestamp-based IDs (>1000000000)
|
||||||
|
const isLocalBeneficiary = (id: string | number): boolean => {
|
||||||
|
const numId = typeof id === 'string' ? parseInt(id, 10) : id;
|
||||||
|
return numId > 1000000000;
|
||||||
|
};
|
||||||
|
|
||||||
interface Invitation {
|
interface Invitation {
|
||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
@ -39,8 +46,9 @@ interface Invitation {
|
|||||||
|
|
||||||
export default function ShareAccessScreen() {
|
export default function ShareAccessScreen() {
|
||||||
const { id } = useLocalSearchParams<{ id: string }>();
|
const { id } = useLocalSearchParams<{ id: string }>();
|
||||||
const { currentBeneficiary } = useBeneficiary();
|
const { currentBeneficiary, localBeneficiaries, updateLocalBeneficiary } = useBeneficiary();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [role, setRole] = useState<Role>('caretaker');
|
const [role, setRole] = useState<Role>('caretaker');
|
||||||
@ -52,16 +60,34 @@ export default function ShareAccessScreen() {
|
|||||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||||
const currentUserEmail = user?.email?.toLowerCase();
|
const currentUserEmail = user?.email?.toLowerCase();
|
||||||
|
|
||||||
|
// Check if this is a local beneficiary
|
||||||
|
const isLocal = useMemo(() => id ? isLocalBeneficiary(id) : false, [id]);
|
||||||
|
|
||||||
|
// Get local beneficiary data
|
||||||
|
const localBeneficiary = useMemo(() => {
|
||||||
|
if (!isLocal || !id) return null;
|
||||||
|
return localBeneficiaries.find(b => b.id === parseInt(id, 10));
|
||||||
|
}, [isLocal, id, localBeneficiaries]);
|
||||||
|
|
||||||
// Load invitations on mount
|
// Load invitations on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (id) {
|
if (id) {
|
||||||
loadInvitations();
|
loadInvitations();
|
||||||
}
|
}
|
||||||
}, [id]);
|
}, [id, isLocal, localBeneficiary]);
|
||||||
|
|
||||||
const loadInvitations = async () => {
|
const loadInvitations = async () => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
|
|
||||||
|
// For local beneficiaries, use stored invitations
|
||||||
|
if (isLocal && localBeneficiary) {
|
||||||
|
setInvitations(localBeneficiary.invitations || []);
|
||||||
|
setIsLoadingInvitations(false);
|
||||||
|
setRefreshing(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For API beneficiaries
|
||||||
try {
|
try {
|
||||||
const response = await api.getInvitations(id);
|
const response = await api.getInvitations(id);
|
||||||
if (response.ok && response.data) {
|
if (response.ok && response.data) {
|
||||||
@ -113,6 +139,35 @@ export default function ShareAccessScreen() {
|
|||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// For local beneficiaries, store invitation locally
|
||||||
|
if (isLocal && id) {
|
||||||
|
try {
|
||||||
|
const newInvitation: Invitation = {
|
||||||
|
id: `local_${Date.now()}`,
|
||||||
|
email: trimmedEmail,
|
||||||
|
role: role,
|
||||||
|
status: 'pending',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentInvitations = localBeneficiary?.invitations || [];
|
||||||
|
await updateLocalBeneficiary(parseInt(id, 10), {
|
||||||
|
invitations: [...currentInvitations, newInvitation],
|
||||||
|
});
|
||||||
|
|
||||||
|
setEmail('');
|
||||||
|
setRole('caretaker');
|
||||||
|
setInvitations([...currentInvitations, newInvitation]);
|
||||||
|
toast.success('Invitation Sent', `Invitation sent to ${trimmedEmail}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error', 'Failed to send invitation');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For API beneficiaries
|
||||||
try {
|
try {
|
||||||
const response = await api.sendInvitation({
|
const response = await api.sendInvitation({
|
||||||
beneficiaryId: id!,
|
beneficiaryId: id!,
|
||||||
@ -124,7 +179,7 @@ export default function ShareAccessScreen() {
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setRole('caretaker');
|
setRole('caretaker');
|
||||||
loadInvitations();
|
loadInvitations();
|
||||||
Alert.alert('Success', `Invitation sent to ${trimmedEmail}`);
|
toast.success('Success', `Invitation sent to ${trimmedEmail}`);
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
|
Alert.alert('Error', response.error?.message || 'Failed to send invitation');
|
||||||
}
|
}
|
||||||
@ -146,10 +201,28 @@ export default function ShareAccessScreen() {
|
|||||||
text: 'Remove',
|
text: 'Remove',
|
||||||
style: 'destructive',
|
style: 'destructive',
|
||||||
onPress: async () => {
|
onPress: async () => {
|
||||||
|
// For local beneficiaries
|
||||||
|
if (isLocal && id) {
|
||||||
|
try {
|
||||||
|
const currentInvitations = localBeneficiary?.invitations || [];
|
||||||
|
const updatedInvitations = currentInvitations.filter(inv => inv.id !== invitation.id);
|
||||||
|
await updateLocalBeneficiary(parseInt(id, 10), {
|
||||||
|
invitations: updatedInvitations,
|
||||||
|
});
|
||||||
|
setInvitations(updatedInvitations);
|
||||||
|
toast.success('Removed', 'Access removed successfully');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error', 'Failed to remove access');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For API beneficiaries
|
||||||
try {
|
try {
|
||||||
const response = await api.deleteInvitation(invitation.id);
|
const response = await api.deleteInvitation(invitation.id);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
loadInvitations();
|
loadInvitations();
|
||||||
|
toast.success('Removed', 'Access removed successfully');
|
||||||
} else {
|
} else {
|
||||||
Alert.alert('Error', 'Failed to remove access');
|
Alert.alert('Error', 'Failed to remove access');
|
||||||
}
|
}
|
||||||
@ -167,6 +240,54 @@ export default function ShareAccessScreen() {
|
|||||||
return 'Caretaker';
|
return 'Caretaker';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleChangeRole = (invitation: Invitation) => {
|
||||||
|
const newRole = invitation.role === 'caretaker' ? 'guardian' : 'caretaker';
|
||||||
|
const newRoleLabel = newRole === 'guardian' ? 'Guardian' : 'Caretaker';
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'Change Role',
|
||||||
|
`Change ${invitation.email}'s role to ${newRoleLabel}?`,
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Change',
|
||||||
|
onPress: async () => {
|
||||||
|
// For local beneficiaries
|
||||||
|
if (isLocal && id) {
|
||||||
|
try {
|
||||||
|
const currentInvitations = localBeneficiary?.invitations || [];
|
||||||
|
const updatedInvitations = currentInvitations.map(inv =>
|
||||||
|
inv.id === invitation.id ? { ...inv, role: newRole } : inv
|
||||||
|
);
|
||||||
|
await updateLocalBeneficiary(parseInt(id, 10), {
|
||||||
|
invitations: updatedInvitations,
|
||||||
|
});
|
||||||
|
setInvitations(updatedInvitations);
|
||||||
|
toast.success('Updated', `Role changed to ${newRoleLabel}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Error', 'Failed to change role');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For API beneficiaries
|
||||||
|
try {
|
||||||
|
const response = await api.updateInvitation(invitation.id, newRole as 'caretaker' | 'guardian');
|
||||||
|
if (response.ok) {
|
||||||
|
loadInvitations();
|
||||||
|
toast.success('Updated', `Role changed to ${newRoleLabel}`);
|
||||||
|
} else {
|
||||||
|
Alert.alert('Error', response.error?.message || 'Failed to change role');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
Alert.alert('Error', 'Failed to change role');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const getStatusColor = (status: string): string => {
|
const getStatusColor = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'accepted': return AppColors.success;
|
case 'accepted': return AppColors.success;
|
||||||
@ -282,9 +403,15 @@ export default function ShareAccessScreen() {
|
|||||||
{invitation.email}
|
{invitation.email}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.invitationMeta}>
|
<View style={styles.invitationMeta}>
|
||||||
<Text style={styles.invitationRole}>
|
<TouchableOpacity
|
||||||
{getRoleLabel(invitation.role)}
|
style={styles.roleChip}
|
||||||
</Text>
|
onPress={() => handleChangeRole(invitation)}
|
||||||
|
>
|
||||||
|
<Text style={styles.invitationRole}>
|
||||||
|
{getRoleLabel(invitation.role)}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={12} color={AppColors.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
<View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} />
|
<View style={[styles.statusDot, { backgroundColor: getStatusColor(invitation.status) }]} />
|
||||||
<Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}>
|
<Text style={[styles.invitationStatus, { color: getStatusColor(invitation.status) }]}>
|
||||||
{invitation.status}
|
{invitation.status}
|
||||||
@ -468,9 +595,19 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
roleChip: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: AppColors.primaryLighter,
|
||||||
|
paddingHorizontal: Spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: BorderRadius.full,
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
invitationRole: {
|
invitationRole: {
|
||||||
fontSize: FontSizes.xs,
|
fontSize: FontSizes.xs,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.primary,
|
||||||
|
fontWeight: FontWeights.medium,
|
||||||
},
|
},
|
||||||
statusDot: {
|
statusDot: {
|
||||||
width: 6,
|
width: 6,
|
||||||
|
|||||||
@ -157,22 +157,15 @@ export default function HomeScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const onboardingCompleted = await api.isOnboardingCompleted();
|
const onboardingCompleted = await api.isOnboardingCompleted();
|
||||||
const response = await api.getAllBeneficiaries();
|
|
||||||
|
|
||||||
let allBeneficiaries: Beneficiary[] = [];
|
// Use only local beneficiaries (no API fetch)
|
||||||
|
setBeneficiaries(localBeneficiaries);
|
||||||
|
|
||||||
if (response.ok && response.data) {
|
if (!currentBeneficiary && localBeneficiaries.length > 0) {
|
||||||
allBeneficiaries = [...response.data];
|
setCurrentBeneficiary(localBeneficiaries[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
allBeneficiaries = [...allBeneficiaries, ...localBeneficiaries];
|
if (localBeneficiaries.length === 0 && !onboardingCompleted) {
|
||||||
setBeneficiaries(allBeneficiaries);
|
|
||||||
|
|
||||||
if (!currentBeneficiary && allBeneficiaries.length > 0) {
|
|
||||||
setCurrentBeneficiary(allBeneficiaries[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (allBeneficiaries.length === 0 && !onboardingCompleted) {
|
|
||||||
router.replace({
|
router.replace({
|
||||||
pathname: '/(auth)/add-loved-one',
|
pathname: '/(auth)/add-loved-one',
|
||||||
params: { email: user?.email || '' },
|
params: { email: user?.email || '' },
|
||||||
@ -181,11 +174,7 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load beneficiaries:', err);
|
console.error('Failed to load beneficiaries:', err);
|
||||||
if (localBeneficiaries.length > 0) {
|
setBeneficiaries(localBeneficiaries);
|
||||||
setBeneficiaries(localBeneficiaries);
|
|
||||||
} else {
|
|
||||||
setError('Failed to load beneficiaries');
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import * as Clipboard from 'expo-clipboard';
|
|||||||
import { router } from 'expo-router';
|
import { router } from 'expo-router';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
import { ProfileDrawer } from '@/components/ProfileDrawer';
|
||||||
|
import { useToast } from '@/components/ui/Toast';
|
||||||
import {
|
import {
|
||||||
AppColors,
|
AppColors,
|
||||||
BorderRadius,
|
BorderRadius,
|
||||||
@ -42,6 +43,7 @@ const generateInviteCode = (identifier: string): string => {
|
|||||||
|
|
||||||
export default function ProfileScreen() {
|
export default function ProfileScreen() {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
|
const toast = useToast();
|
||||||
|
|
||||||
// Drawer state
|
// Drawer state
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false);
|
const [drawerVisible, setDrawerVisible] = useState(false);
|
||||||
@ -129,7 +131,7 @@ export default function ProfileScreen() {
|
|||||||
|
|
||||||
const handleCopyInviteCode = async () => {
|
const handleCopyInviteCode = async () => {
|
||||||
await Clipboard.setStringAsync(inviteCode);
|
await Clipboard.setStringAsync(inviteCode);
|
||||||
Alert.alert('Copied!', `Invite code "${inviteCode}" copied to clipboard`);
|
toast.success(`Invite code "${inviteCode}" copied to clipboard`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const router = express.Router();
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const { supabase } = require('../config/supabase');
|
const { supabase } = require('../config/supabase');
|
||||||
|
const { sendInvitationEmail } = require('../services/email');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Middleware to verify JWT token
|
* Middleware to verify JWT token
|
||||||
@ -104,10 +105,37 @@ router.post('/', async (req, res) => {
|
|||||||
return res.status(500).json({ error: 'Failed to create invitation' });
|
return res.status(500).json({ error: 'Failed to create invitation' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Send invitation email if email provided
|
// Send invitation email if email provided
|
||||||
|
let emailSent = false;
|
||||||
|
if (email) {
|
||||||
|
// Get inviter and beneficiary names for email
|
||||||
|
const { data: inviter } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('first_name, last_name, email')
|
||||||
|
.eq('id', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const { data: beneficiaryData } = await supabase
|
||||||
|
.from('users')
|
||||||
|
.select('first_name, last_name')
|
||||||
|
.eq('id', beneficiaryId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
const inviterName = inviter ? `${inviter.first_name || ''} ${inviter.last_name || ''}`.trim() || inviter.email : null;
|
||||||
|
const beneficiaryName = beneficiaryData ? `${beneficiaryData.first_name || ''} ${beneficiaryData.last_name || ''}`.trim() : null;
|
||||||
|
|
||||||
|
emailSent = await sendInvitationEmail({
|
||||||
|
email: email.toLowerCase(),
|
||||||
|
inviterName,
|
||||||
|
beneficiaryName,
|
||||||
|
role,
|
||||||
|
inviteCode: inviteToken
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
emailSent,
|
||||||
invitation: {
|
invitation: {
|
||||||
id: invitation.id,
|
id: invitation.id,
|
||||||
code: invitation.token,
|
code: invitation.token,
|
||||||
@ -289,6 +317,69 @@ router.get('/', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PATCH /api/invitations/:id
|
||||||
|
* Updates invitation role
|
||||||
|
*/
|
||||||
|
router.patch('/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const userId = req.user.userId;
|
||||||
|
const invitationId = parseInt(req.params.id, 10);
|
||||||
|
const { role } = req.body;
|
||||||
|
|
||||||
|
if (!role || !['caretaker', 'guardian'].includes(role)) {
|
||||||
|
return res.status(400).json({ error: 'Invalid role. Must be: caretaker or guardian' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check invitation belongs to user
|
||||||
|
const { data: invitation, error: findError } = await supabase
|
||||||
|
.from('invitations')
|
||||||
|
.select('*')
|
||||||
|
.eq('id', invitationId)
|
||||||
|
.eq('invited_by', userId)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (findError || !invitation) {
|
||||||
|
return res.status(404).json({ error: 'Invitation not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update invitation role
|
||||||
|
const { data: updated, error } = await supabase
|
||||||
|
.from('invitations')
|
||||||
|
.update({ role })
|
||||||
|
.eq('id', invitationId)
|
||||||
|
.select()
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error('Update invitation error:', error);
|
||||||
|
return res.status(500).json({ error: 'Failed to update invitation' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If invitation was already accepted, also update user_access
|
||||||
|
if (invitation.accepted_by) {
|
||||||
|
await supabase
|
||||||
|
.from('user_access')
|
||||||
|
.update({ role })
|
||||||
|
.eq('accessor_id', invitation.accepted_by)
|
||||||
|
.eq('beneficiary_id', invitation.beneficiary_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
invitation: {
|
||||||
|
id: updated.id,
|
||||||
|
role: updated.role,
|
||||||
|
email: updated.email
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Update invitation error:', error);
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DELETE /api/invitations/:id
|
* DELETE /api/invitations/:id
|
||||||
* Revokes a pending invitation
|
* Revokes a pending invitation
|
||||||
|
|||||||
@ -216,8 +216,124 @@ If you didn't request this code, you can safely ignore this email.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send invitation email to share beneficiary access
|
||||||
|
*/
|
||||||
|
async function sendInvitationEmail({ email, inviterName, beneficiaryName, role, inviteCode }) {
|
||||||
|
const frontendUrl = process.env.FRONTEND_URL || 'https://wellnuo.smartlaunchhub.com';
|
||||||
|
const acceptLink = `${frontendUrl}/accept-invite?code=${inviteCode}`;
|
||||||
|
const roleText = role === 'guardian' ? 'Guardian (full access)' : 'Caretaker (view only)';
|
||||||
|
|
||||||
|
const htmlContent = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #F5F7FA; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="background-color: #F5F7FA;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding: 48px 24px;">
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0" style="max-width: 500px; background-color: #FFFFFF; border-radius: 16px; overflow: hidden; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<tr>
|
||||||
|
<td style="background: linear-gradient(135deg, #4A90D9 0%, #357ABD 100%); padding: 32px; text-align: center;">
|
||||||
|
<h1 style="margin: 0; font-size: 24px; font-weight: 700; color: #FFFFFF;">WellNuo</h1>
|
||||||
|
<p style="margin: 8px 0 0 0; font-size: 14px; color: rgba(255,255,255,0.9);">Elderly Care Monitoring</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
<h2 style="margin: 0 0 16px 0; font-size: 20px; font-weight: 600; color: #333333;">You've been invited!</h2>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 24px 0; font-size: 15px; color: #666666; line-height: 1.6;">
|
||||||
|
<strong>${inviterName || 'Someone'}</strong> has invited you to help monitor
|
||||||
|
<strong>${beneficiaryName || 'their loved one'}</strong> on WellNuo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Role Badge -->
|
||||||
|
<div style="background-color: #F0F7FF; border-radius: 8px; padding: 16px; margin-bottom: 24px;">
|
||||||
|
<p style="margin: 0; font-size: 13px; color: #666666;">Your role:</p>
|
||||||
|
<p style="margin: 4px 0 0 0; font-size: 16px; font-weight: 600; color: #4A90D9;">${roleText}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Invite Code -->
|
||||||
|
<div style="background-color: #F5F7FA; border-radius: 8px; padding: 20px; text-align: center; margin-bottom: 24px;">
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 13px; color: #666666;">Your invitation code:</p>
|
||||||
|
<p style="margin: 0; font-size: 28px; font-weight: 700; letter-spacing: 3px; color: #333333;">${inviteCode}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Button -->
|
||||||
|
<table width="100%" cellspacing="0" cellpadding="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<a href="${acceptLink}" style="display: inline-block; padding: 14px 32px; background: linear-gradient(135deg, #4A90D9 0%, #357ABD 100%); color: #FFFFFF; text-decoration: none; border-radius: 8px; font-size: 16px; font-weight: 600;">Accept Invitation</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p style="margin: 24px 0 0 0; font-size: 13px; color: #999999; text-align: center;">
|
||||||
|
This invitation expires in 7 days.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 24px; background-color: #F5F7FA; text-align: center;">
|
||||||
|
<p style="margin: 0; font-size: 12px; color: #999999;">
|
||||||
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const textContent = `
|
||||||
|
WellNuo - You've been invited!
|
||||||
|
|
||||||
|
${inviterName || 'Someone'} has invited you to help monitor ${beneficiaryName || 'their loved one'} on WellNuo.
|
||||||
|
|
||||||
|
Your role: ${roleText}
|
||||||
|
|
||||||
|
Your invitation code: ${inviteCode}
|
||||||
|
|
||||||
|
Accept your invitation: ${acceptLink}
|
||||||
|
|
||||||
|
This invitation expires in 7 days.
|
||||||
|
|
||||||
|
If you didn't expect this invitation, you can safely ignore this email.
|
||||||
|
|
||||||
|
WellNuo - Elderly Care Monitoring
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sendEmail({
|
||||||
|
to: email,
|
||||||
|
subject: `${inviterName || 'Someone'} invited you to WellNuo`,
|
||||||
|
htmlContent,
|
||||||
|
textContent
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send invitation email:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
sendEmail,
|
sendEmail,
|
||||||
sendPasswordResetEmail,
|
sendPasswordResetEmail,
|
||||||
sendOTPEmail
|
sendOTPEmail,
|
||||||
|
sendInvitationEmail
|
||||||
};
|
};
|
||||||
|
|||||||
69
components/ui/DevModeToggle.tsx
Normal file
69
components/ui/DevModeToggle.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, Switch, StyleSheet } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import {
|
||||||
|
AppColors,
|
||||||
|
BorderRadius,
|
||||||
|
FontSizes,
|
||||||
|
Spacing,
|
||||||
|
FontWeights,
|
||||||
|
} from '@/constants/theme';
|
||||||
|
|
||||||
|
interface DevModeToggleProps {
|
||||||
|
value: boolean;
|
||||||
|
onValueChange: (value: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DevModeToggle({
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
label = 'Developer Mode',
|
||||||
|
hint = 'Show WebView dashboard',
|
||||||
|
}: DevModeToggleProps) {
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.left}>
|
||||||
|
<Ionicons name="code-slash" size={20} color={AppColors.warning} />
|
||||||
|
<View>
|
||||||
|
<Text style={styles.label}>{label}</Text>
|
||||||
|
<Text style={styles.hint}>{hint}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Switch
|
||||||
|
value={value}
|
||||||
|
onValueChange={onValueChange}
|
||||||
|
trackColor={{ false: AppColors.border, true: AppColors.primaryLight }}
|
||||||
|
thumbColor={value ? AppColors.primary : AppColors.textMuted}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
backgroundColor: AppColors.warningLight,
|
||||||
|
borderRadius: BorderRadius.lg,
|
||||||
|
padding: Spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: AppColors.warning,
|
||||||
|
},
|
||||||
|
left: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: Spacing.md,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
fontSize: FontSizes.sm,
|
||||||
|
fontWeight: FontWeights.semibold,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
fontSize: FontSizes.xs,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
},
|
||||||
|
});
|
||||||
@ -653,6 +653,41 @@ class ApiService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update invitation role
|
||||||
|
async updateInvitation(invitationId: string, role: 'caretaker' | 'guardian'): Promise<ApiResponse<{ success: boolean; invitation: { id: string; role: string; email: string } }>> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
if (!token) {
|
||||||
|
return { ok: false, error: { message: 'Not authenticated', code: 'UNAUTHORIZED' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${WELLNUO_API_URL}/invitations/${invitationId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ role }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
return { data, ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: data.error || 'Failed to update invitation' },
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: { message: 'Network error. Please check your connection.' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const api = new ApiService();
|
export const api = new ApiService();
|
||||||
|
|||||||
@ -79,6 +79,15 @@ export interface Beneficiary {
|
|||||||
equipmentStatus?: EquipmentStatus;
|
equipmentStatus?: EquipmentStatus;
|
||||||
trackingNumber?: string; // Shipping tracking number
|
trackingNumber?: string; // Shipping tracking number
|
||||||
isDemo?: boolean; // Demo mode flag
|
isDemo?: boolean; // Demo mode flag
|
||||||
|
// Invitations for sharing access
|
||||||
|
invitations?: {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
role: string;
|
||||||
|
label?: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard API response
|
// Dashboard API response
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user